From 4fa304306b95eb80c3447bfb0a0f258ceb29a36d Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 19 Feb 2026 17:47:34 -0800 Subject: [PATCH 01/39] tests: centralize in-flight turn cleanup helper (#12271) ## Why Several tests intentionally exercise behavior while a turn is still active. The cleanup sequence for those tests (`turn/interrupt` + waiting for `codex/event/turn_aborted`) was duplicated across files, which made the rationale easy to lose and the pattern easy to apply inconsistently. This change centralizes that cleanup in one place with a single explanatory doc comment. ## What Changed ### Added shared helper In `codex-rs/app-server/tests/common/mcp_process.rs`: - Added `McpProcess::interrupt_turn_and_wait_for_aborted(...)`. - Added a doc comment explaining why explicit interrupt + terminal wait is required for tests that intentionally leave a turn in-flight. ### Migrated call sites Replaced duplicated interrupt/aborted blocks with the helper in: - `codex-rs/app-server/tests/suite/v2/thread_resume.rs` - `thread_resume_rejects_history_when_thread_is_running` - `thread_resume_rejects_mismatched_path_when_thread_is_running` - `codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs` - `turn_start_shell_zsh_fork_executes_command_v2` - `turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2` - `codex-rs/app-server/tests/suite/v2/turn_steer.rs` - `turn_steer_returns_active_turn_id` ### Existing cleanup retained In `codex-rs/app-server/tests/suite/v2/turn_start.rs`: - `turn_start_accepts_local_image_input` continues to explicitly wait for `turn/completed` so the turn lifecycle is fully drained before test exit. ## Verification - `cargo test -p codex-app-server` --- MODULE.bazel.lock | 4 - .../app-server/tests/common/mcp_process.rs | 77 +++++++++++++++++++ .../tests/suite/v2/thread_resume.rs | 66 +++++----------- .../app-server/tests/suite/v2/turn_start.rs | 7 +- .../tests/suite/v2/turn_start_zsh_fork.rs | 15 +++- .../app-server/tests/suite/v2/turn_steer.rs | 5 +- 6 files changed, 117 insertions(+), 57 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index c1ab56b653..03ad23ff77 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -612,10 +612,6 @@ "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "askama_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"askama_macros\",\"optional\":true,\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"itoa\",\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"askama_macros?/alloc\",\"serde?/alloc\",\"serde_json?/alloc\",\"percent-encoding?/alloc\"],\"code-in-doc\":[\"askama_macros?/code-in-doc\"],\"config\":[\"askama_macros?/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[\"dep:askama_macros\",\"dep:askama_macros\"],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_macros/nightly-spans\"],\"serde_json\":[\"std\",\"askama_macros?/serde_json\",\"dep:serde\",\"dep:serde_json\"],\"std\":[\"alloc\",\"askama_macros?/std\",\"serde?/std\",\"serde_json?/std\",\"percent-encoding?/std\"],\"urlencode\":[\"askama_macros?/urlencode\",\"dep:percent-encoding\"]}}", - "askama_derive_0.15.4": "{\"dependencies\":[{\"name\":\"basic-toml\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2\"},{\"name\":\"parser\",\"package\":\"askama_parser\",\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.6.0\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.3\"}],\"features\":{\"alloc\":[],\"code-in-doc\":[\"dep:pulldown-cmark\"],\"config\":[\"external-sources\",\"dep:basic-toml\",\"dep:serde\",\"dep:serde_derive\",\"parser/config\"],\"default\":[\"alloc\",\"code-in-doc\",\"config\",\"external-sources\",\"proc-macro\",\"serde_json\",\"std\",\"urlencode\"],\"external-sources\":[],\"nightly-spans\":[],\"proc-macro\":[\"proc-macro2/proc-macro\"],\"serde_json\":[],\"std\":[\"alloc\"],\"urlencode\":[]}}", - "askama_macros_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"external-sources\",\"proc-macro\"],\"name\":\"askama_derive\",\"package\":\"askama_derive\",\"req\":\"=0.15.4\"}],\"features\":{\"alloc\":[\"askama_derive/alloc\"],\"code-in-doc\":[\"askama_derive/code-in-doc\"],\"config\":[\"askama_derive/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_derive/nightly-spans\"],\"serde_json\":[\"askama_derive/serde_json\"],\"std\":[\"askama_derive/std\"],\"urlencode\":[\"askama_derive/urlencode\"]}}", - "askama_parser_0.15.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0.12\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.0\"}],\"features\":{\"config\":[\"dep:serde\",\"dep:serde_derive\"]}}", "asn1-rs-derive_0.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}", "asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "asn1-rs_0.7.1": "{\"dependencies\":[{\"name\":\"asn1-rs-derive\",\"req\":\"^0.6\"},{\"name\":\"asn1-rs-impl\",\"req\":\"^0.2\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"colored\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^3.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"displaydoc\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pem\",\"req\":\"^3.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"macros\",\"parsing\",\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"bits\":[\"bitvec\"],\"datetime\":[\"time\"],\"debug\":[\"std\",\"colored\"],\"default\":[\"std\"],\"serialize\":[\"cookie-factory\"],\"std\":[],\"trace\":[\"debug\"]}}", diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 77212f9438..249a280213 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -61,6 +61,7 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnSteerParams; @@ -572,6 +573,63 @@ impl McpProcess { self.send_request("turn/interrupt", params).await } + /// Deterministically clean up an intentionally in-flight turn. + /// + /// Some tests assert behavior while a turn is still running. Returning from those tests + /// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work + /// racing teardown and intermittently show up as `LEAK` in nextest. + /// + /// In rare races, the turn can also fail or complete on its own after we send + /// `turn/interrupt` but before the server emits the interrupt response. The helper treats a + /// buffered matching `turn/completed` notification as sufficient terminal cleanup in that + /// case so teardown does not flap on timing. + pub async fn interrupt_turn_and_wait_for_aborted( + &mut self, + thread_id: String, + turn_id: String, + read_timeout: std::time::Duration, + ) -> anyhow::Result<()> { + let interrupt_request_id = self + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }) + .await?; + match tokio::time::timeout( + read_timeout, + self.read_stream_until_response_message(RequestId::Integer(interrupt_request_id)), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for turn interrupt response")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err).with_context(|| "timed out waiting for turn interrupt response"); + } + } + match tokio::time::timeout( + read_timeout, + self.read_stream_until_notification_message("codex/event/turn_aborted"), + ) + .await + { + Ok(result) => { + result.with_context(|| "failed while waiting for turn aborted notification")?; + } + Err(err) => { + if self.pending_turn_completed_notification(&thread_id, &turn_id) { + return Ok(()); + } + return Err(err).with_context(|| "timed out waiting for turn aborted notification"); + } + } + Ok(()) + } + /// Send a `turn/steer` JSON-RPC request (v2). pub async fn send_turn_steer_request( &mut self, @@ -940,6 +998,25 @@ impl McpProcess { None } + fn pending_turn_completed_notification(&self, thread_id: &str, turn_id: &str) -> bool { + self.pending_messages.iter().any(|message| { + let JSONRPCMessage::Notification(notification) = message else { + return false; + }; + if notification.method != "turn/completed" { + return false; + } + let Some(params) = notification.params.as_ref() else { + return false; + }; + let Ok(payload) = serde_json::from_value::(params.clone()) + else { + return false; + }; + payload.thread_id == thread_id && payload.turn.id == turn_id + }) + } + fn message_request_id(message: &JSONRPCMessage) -> Option<&RequestId> { match message { JSONRPCMessage::Request(request) => Some(&request.id), 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 4a57767ab8..15bc4012eb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -15,8 +15,6 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; -use codex_app_server_protocol::TurnInterruptParams; -use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -317,9 +315,14 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); - let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); let _first_response_mock = responses::mount_sse_once(&server, first_body).await; - let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -413,28 +416,9 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { resume_err.error.message ); - // This test intentionally keeps a turn running to exercise the resume error path. - // Keep this explicit interrupt + turn_aborted wait so teardown does not leave - // in-flight work behind (which can show up as LEAK in nextest). - let interrupt_id = primary - .send_turn_interrupt_request(TurnInterruptParams { - thread_id, - turn_id: running_turn.id, - }) + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; - let interrupt_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)), - ) - .await??; - let _turn_interrupt_response: TurnInterruptResponse = - to_response::(interrupt_resp)?; - - timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_notification_message("codex/event/turn_aborted"), - ) - .await??; Ok(()) } @@ -447,9 +431,14 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul responses::ev_assistant_message("msg-1", "Done"), responses::ev_completed("resp-1"), ]); - let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); let _first_response_mock = responses::mount_sse_once(&server, first_body).await; - let _second_response_mock = responses::mount_sse_once(&server, second_body).await; + let _second_response_mock = responses::mount_response_once(&server, second_response).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -533,28 +522,9 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul resume_err.error.message ); - // This test intentionally keeps a turn running to exercise the resume error path. - // Keep this explicit interrupt + turn_aborted wait so teardown does not leave - // in-flight work behind (which can show up as LEAK in nextest). - let interrupt_id = primary - .send_turn_interrupt_request(TurnInterruptParams { - thread_id, - turn_id: running_turn.id, - }) + primary + .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; - let interrupt_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)), - ) - .await??; - let _turn_interrupt_response: TurnInterruptResponse = - to_response::(interrupt_resp)?; - - timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_notification_message("codex/event/turn_aborted"), - ) - .await??; 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 2eecfc5590..d2d850176d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -747,7 +747,12 @@ async fn turn_start_accepts_local_image_input() -> Result<()> { let TurnStartResponse { turn } = to_response::(turn_resp)?; assert!(!turn.id.is_empty()); - // This test only validates that turn/start responds and returns a turn. + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 4233ce6494..4be127dff8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -29,6 +29,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::features::FEATURES; @@ -100,7 +101,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "run echo hi".to_string(), text_elements: Vec::new(), @@ -114,11 +115,12 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { ..Default::default() }) .await?; - timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { @@ -149,6 +151,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { assert!(command.contains(" -lc 'echo hi'")); assert_eq!(cwd, workspace); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } @@ -504,11 +509,12 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() ..Default::default() }) .await?; - timeout( + let turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), ) .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; let mut approval_ids = Vec::new(); for decision in [ @@ -577,6 +583,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() assert_eq!(approval_ids.len(), 2); assert_ne!(approval_ids[0], approval_ids[1]); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 89704326fd..779e77577b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -136,7 +136,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { - thread_id: thread.id, + thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), @@ -152,6 +152,9 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) + .await?; + Ok(()) } From 366ecaf17a6902243c54d805a16829342a719cec Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 19 Feb 2026 18:27:18 -0800 Subject: [PATCH 02/39] app-server: fix flaky list_apps_returns_connectors_with_accessible_flags test (#12286) ## Why `app/list` emits `app/list/updated` after whichever async load finishes first (directory connectors or accessible tools). This test assumed the directory-backed update always arrived first because it injected a tools delay, but that assumption is not stable when the process-global Codex Apps tools cache is already warm. In that case the accessible-tools path can return immediately and the first notification shape flips, which makes the assertion flaky. Relevant code paths: - [`codex-rs/app-server/src/codex_message_processor.rs`](https://github.com/openai/codex/blob/13ec97d72e3482f16c62e0a22025a0542133e623/codex-rs/app-server/src/codex_message_processor.rs#L4949-L5034) (concurrent loads + per-load `app/list/updated` notifications) - [`codex-rs/core/src/mcp_connection_manager.rs`](https://github.com/openai/codex/blob/13ec97d72e3482f16c62e0a22025a0542133e623/codex-rs/core/src/mcp_connection_manager.rs#L1182-L1197) (Codex Apps tools cache hit path) ## What Changed Updated `suite::v2::app_list::list_apps_returns_connectors_with_accessible_flags` in `codex-rs/app-server/tests/suite/v2/app_list.rs` to accept either valid first `app/list/updated` payload: - the directory-first snapshot - the accessible-tools-first snapshot The test still keeps the later assertions strict: - the second `app/list/updated` notification must be the fully merged result - the final `app/list` response must match the same merged result I also added an inline comment explaining why the first notification is intentionally order-insensitive. ## Verification - `cargo test -p codex-app-server` --- .../app-server/tests/suite/v2/app_list.rs | 85 ++++++++++++------- 1 file changed, 53 insertions(+), 32 deletions(-) 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 1fd3f5a447..3a2064d4cb 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -491,39 +491,60 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { }) .await?; + let expected_directory_first = vec![ + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + }, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: false, + is_enabled: true, + }, + ]; + let expected_accessible_first = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), + is_accessible: true, + is_enabled: true, + }]; + let first_update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!( - first_update.data, - vec![ - AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha connector".to_string()), - logo_url: Some("https://example.com/alpha.png".to_string()), - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: false, - is_enabled: true, - }, - AppInfo { - id: "beta".to_string(), - name: "beta".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), - is_accessible: false, - is_enabled: true, - }, - ] + // app/list emits an update after whichever async load finishes first. Even with + // a tools delay in this test, the accessible-tools path can return first if the + // process-global Codex Apps tools cache is warm from another test. + assert!( + first_update.data == expected_directory_first + || first_update.data == expected_accessible_first, + "unexpected first app/list update: {:#?}", + first_update.data ); let expected = vec![ From b06f91c4fe52088a0ccbdcadeec24997cdfdb770 Mon Sep 17 00:00:00 2001 From: Max Johnson <162359438+maxj-oai@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:29:05 -0800 Subject: [PATCH 03/39] app-server: improve thread resume rejoin flow (#11776) thread/resume response includes latest turn with all items, in band so no events are stale or lost Testing - e2e tested using app-server-test-client using flow described in "Testing Thread Rejoin Behavior" in codex-rs/app-server-test-client/README.md - e2e tested in codex desktop by reconnecting to a running turn --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-protocol/Cargo.toml | 1 + .../src/protocol/thread_history.rs | 577 +++++++++++++++--- .../app-server-protocol/src/protocol/v2.rs | 20 + .../app-server/src/bespoke_event_handling.rs | 43 +- .../app-server/src/codex_message_processor.rs | 220 +++++-- codex-rs/app-server/src/lib.rs | 4 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/thread_state.rs | 60 +- 9 files changed, 733 insertions(+), 196 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4e3ec84e8f..20099b02eb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1354,6 +1354,7 @@ dependencies = [ "strum_macros 0.27.2", "tempfile", "thiserror 2.0.18", + "tracing", "ts-rs", "uuid", ] diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 2ab6f291f6..f5df7af034 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -25,6 +25,7 @@ strum_macros = { workspace = true } thiserror = { workspace = true } ts-rs = { workspace = true } inventory = { workspace = true } +tracing = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index e5005077e5..52850a67e4 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -24,9 +24,13 @@ use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ContextCompactedEvent; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::RolloutItem; @@ -36,8 +40,10 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::ViewImageToolCallEvent; +use codex_protocol::protocol::WebSearchBeginEvent; use codex_protocol::protocol::WebSearchEndEvent; use std::collections::HashMap; +use tracing::warn; use uuid::Uuid; #[cfg(test)] @@ -57,14 +63,20 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { builder.finish() } -struct ThreadHistoryBuilder { +pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, } +impl Default for ThreadHistoryBuilder { + fn default() -> Self { + Self::new() + } +} + impl ThreadHistoryBuilder { - fn new() -> Self { + pub fn new() -> Self { Self { turns: Vec::new(), current_turn: None, @@ -72,14 +84,32 @@ impl ThreadHistoryBuilder { } } - fn finish(mut self) -> Vec { + pub fn reset(&mut self) { + *self = Self::new(); + } + + pub fn finish(mut self) -> Vec { self.finish_current_turn(); self.turns } + pub fn active_turn_snapshot(&self) -> Option { + self.current_turn + .as_ref() + .map(Turn::from) + .or_else(|| self.turns.last().cloned()) + } + + pub fn has_active_turn(&self) -> bool { + self.current_turn.is_some() + } + + /// Shared reducer for persisted rollout replay and in-memory current-turn + /// tracking used by running thread resume/rejoin. + /// /// This function should handle all EventMsg variants that can be persisted in a rollout file. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. - fn handle_event(&mut self, event: &EventMsg) { + pub fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), EventMsg::AgentMessage(payload) => { @@ -89,21 +119,35 @@ impl ThreadHistoryBuilder { EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) } + EventMsg::WebSearchBegin(payload) => self.handle_web_search_begin(payload), EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload), + EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload), EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload), + EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload), EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload), + EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload), EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload), EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload), + EventMsg::CollabAgentSpawnBegin(payload) => { + self.handle_collab_agent_spawn_begin(payload) + } EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload), + EventMsg::CollabAgentInteractionBegin(payload) => { + self.handle_collab_agent_interaction_begin(payload) + } EventMsg::CollabAgentInteractionEnd(payload) => { self.handle_collab_agent_interaction_end(payload) } + EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload), EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload), + EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload), EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload), + EventMsg::CollabResumeBegin(payload) => self.handle_collab_resume_begin(payload), EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload), EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload), EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload), EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload), + EventMsg::ItemStarted(payload) => self.handle_item_started(payload), EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} @@ -116,7 +160,7 @@ impl ThreadHistoryBuilder { } } - fn handle_rollout_item(&mut self, item: &RolloutItem) { + pub fn handle_rollout_item(&mut self, item: &RolloutItem) { match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), @@ -199,15 +243,51 @@ impl ThreadHistoryBuilder { }); } - fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { - if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item - && plan.text.is_empty() - { - return; + fn handle_item_started(&mut self, payload: &ItemStartedEvent) { + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); + } + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} } + } - let item = ThreadItem::from(payload.item.clone()); - self.ensure_turn().items.push(item); + fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { + match &payload.item { + codex_protocol::items::TurnItem::Plan(plan) => { + if plan.text.is_empty() { + return; + } + self.upsert_item_in_turn_id( + &payload.turn_id, + ThreadItem::from(payload.item.clone()), + ); + } + codex_protocol::items::TurnItem::UserMessage(_) + | codex_protocol::items::TurnItem::AgentMessage(_) + | codex_protocol::items::TurnItem::Reasoning(_) + | codex_protocol::items::TurnItem::WebSearch(_) + | codex_protocol::items::TurnItem::ContextCompaction(_) => {} + } + } + + fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) { + let item = ThreadItem::WebSearch { + id: payload.call_id.clone(), + query: String::new(), + action: None, + }; + self.upsert_item_in_current_turn(item); } fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { @@ -216,7 +296,30 @@ impl ThreadHistoryBuilder { query: payload.query.clone(), action: Some(WebSearchAction::from(payload.action.clone())), }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); + } + + fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) { + let command = shlex::try_join(payload.command.iter().map(String::as_str)) + .unwrap_or_else(|_| payload.command.join(" ")); + let command_actions = payload + .parsed_cmd + .iter() + .cloned() + .map(CommandAction::from) + .collect(); + let item = ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command, + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + status: CommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + self.upsert_item_in_turn_id(&payload.turn_id, item); } fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { @@ -246,33 +349,25 @@ impl ThreadHistoryBuilder { exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), }; - // Command completions can arrive out of order. Unified exec may return // while a PTY is still running, then emit ExecCommandEnd later from a // background exit watcher when that process finally exits. By then, a // newer user turn may already have started. Route by event turn_id so // replay preserves the original turn association. - if let Some(turn) = self.current_turn.as_mut() - && turn.id == payload.turn_id - { - turn.items.push(item); - return; - } + self.upsert_item_in_turn_id(&payload.turn_id, item); + } - // If the originating turn is already finalized, append there instead - // of attaching to whichever turn is currently active during replay. - if let Some(turn) = self - .turns - .iter_mut() - .find(|turn| turn.id == payload.turn_id) - { - turn.items.push(item); - return; + fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) { + let item = ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); } - - // Backward-compatibility fallback for partial/legacy streams where the - // event turn_id does not match any known replay turn. - self.ensure_turn().items.push(item); } fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) { @@ -282,7 +377,29 @@ impl ThreadHistoryBuilder { changes: convert_patch_changes(&payload.changes), status, }; - self.ensure_turn().items.push(item); + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) { + let item = ThreadItem::McpToolCall { + id: payload.call_id.clone(), + server: payload.invocation.server.clone(), + tool: payload.invocation.tool.clone(), + status: McpToolCallStatus::InProgress, + arguments: payload + .invocation + .arguments + .clone() + .unwrap_or(serde_json::Value::Null), + result: None, + error: None, + duration_ms: None, + }; + self.upsert_item_in_current_turn(item); } fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) { @@ -321,7 +438,7 @@ impl ThreadHistoryBuilder { error, duration_ms, }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); } fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { @@ -329,7 +446,23 @@ impl ThreadHistoryBuilder { id: payload.call_id.clone(), path: payload.path.to_string_lossy().into_owned(), }; - self.ensure_turn().items.push(item); + self.upsert_item_in_current_turn(item); + } + + fn handle_collab_agent_spawn_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(payload.prompt.clone()), + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_agent_spawn_end( @@ -353,17 +486,31 @@ impl ThreadHistoryBuilder { } None => (Vec::new(), HashMap::new()), }; - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::SpawnAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: Some(payload.prompt.clone()), - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SpawnAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(payload.prompt.clone()), + agents_states, + }); + } + + fn handle_collab_agent_interaction_begin( + &mut self, + payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: Some(payload.prompt.clone()), + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_agent_interaction_end( @@ -376,17 +523,35 @@ impl ThreadHistoryBuilder { }; let receiver_id = payload.receiver_thread_id.to_string(); let received_status = CollabAgentState::from(payload.status.clone()); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::SendInput, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id.clone()], - prompt: Some(payload.prompt.clone()), - agents_states: [(receiver_id, received_status)].into_iter().collect(), - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::SendInput, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: Some(payload.prompt.clone()), + agents_states: [(receiver_id, received_status)].into_iter().collect(), + }); + } + + fn handle_collab_waiting_begin( + &mut self, + payload: &codex_protocol::protocol::CollabWaitingBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: payload + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(), + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_waiting_end( @@ -410,17 +575,31 @@ impl ThreadHistoryBuilder { .iter() .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::Wait, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::Wait, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + agents_states, + }); + } + + fn handle_collab_close_begin( + &mut self, + payload: &codex_protocol::protocol::CollabCloseBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) { @@ -435,17 +614,31 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::CloseAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::CloseAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + agents_states, + }); + } + + fn handle_collab_resume_begin( + &mut self, + payload: &codex_protocol::protocol::CollabResumeBeginEvent, + ) { + let item = ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![payload.receiver_thread_id.to_string()], + prompt: None, + agents_states: HashMap::new(), + }; + self.upsert_item_in_current_turn(item); } fn handle_collab_resume_end( @@ -463,17 +656,15 @@ impl ThreadHistoryBuilder { )] .into_iter() .collect(); - self.ensure_turn() - .items - .push(ThreadItem::CollabAgentToolCall { - id: payload.call_id.clone(), - tool: CollabAgentTool::ResumeAgent, - status, - sender_thread_id: payload.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - agents_states, - }); + self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { + id: payload.call_id.clone(), + tool: CollabAgentTool::ResumeAgent, + status, + sender_thread_id: payload.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + agents_states, + }); } fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { @@ -548,6 +739,7 @@ impl ThreadHistoryBuilder { self.finish_current_turn(); self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) + .with_status(TurnStatus::InProgress) .opened_explicitly(), ); } @@ -642,6 +834,30 @@ impl ThreadHistoryBuilder { unreachable!("current turn must exist after initialization"); } + fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) { + if let Some(turn) = self.current_turn.as_mut() + && turn.id == turn_id + { + upsert_turn_item(&mut turn.items, item); + return; + } + + if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { + upsert_turn_item(&mut turn.items, item); + return; + } + + warn!( + item_id = item.id(), + "dropping turn-scoped item for unknown turn id `{turn_id}`" + ); + } + + fn upsert_item_in_current_turn(&mut self, item: ThreadItem) { + let turn = self.ensure_turn(); + upsert_turn_item(&mut turn.items, item); + } + fn next_item_id(&mut self) -> String { let id = format!("item-{}", self.next_item_index); self.next_item_index += 1; @@ -684,7 +900,7 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } -fn convert_patch_changes( +pub fn convert_patch_changes( changes: &HashMap, ) -> Vec { let mut converted: Vec = changes @@ -726,6 +942,17 @@ fn format_file_change_diff(change: &codex_protocol::protocol::FileChange) -> Str } } +fn upsert_turn_item(items: &mut Vec, item: ThreadItem) { + if let Some(existing_item) = items + .iter_mut() + .find(|existing_item| existing_item.id() == item.id()) + { + *existing_item = item; + return; + } + items.push(item); +} + struct PendingTurn { id: String, items: Vec, @@ -744,6 +971,11 @@ impl PendingTurn { self.opened_explicitly = true; self } + + fn with_status(mut self, status: TurnStatus) -> Self { + self.status = status; + self + } } impl From for Turn { @@ -757,10 +989,23 @@ impl From for Turn { } } +impl From<&PendingTurn> for Turn { + fn from(value: &PendingTurn) -> Self { + Self { + id: value.id.clone(), + items: value.items.clone(), + error: value.error.clone(), + status: value.status.clone(), + } + } +} + #[cfg(test)] mod tests { use super::*; use codex_protocol::ThreadId; + use codex_protocol::items::TurnItem as CoreTurnItem; + use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -771,6 +1016,7 @@ mod tests { use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::ThreadRolledBackEvent; @@ -816,11 +1062,11 @@ mod tests { }), ]; - let items = events - .into_iter() - .map(RolloutItem::EventMsg) - .collect::>(); - let turns = build_turns_from_rollout_items(&items); + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); assert_eq!(turns.len(), 2); let first = &turns[0]; @@ -883,6 +1129,55 @@ mod tests { ); } + #[test] + fn ignores_non_plan_item_lifecycle_events() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::UserMessage(CoreUserMessageItem { + id: "user-item-id".to_string(), + content: Vec::new(), + }), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + } + ); + } + #[test] fn preserves_agent_message_phase_in_history() { let events = vec![EventMsg::AgentMessage(AgentMessageEvent { @@ -1212,6 +1507,11 @@ mod tests { #[test] fn reconstructs_tool_items_from_persisted_completion_events() { let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), EventMsg::UserMessage(UserMessageEvent { message: "run tools".into(), images: None, @@ -1311,6 +1611,11 @@ mod tests { #[test] fn reconstructs_declined_exec_and_patch_items() { let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), EventMsg::UserMessage(UserMessageEvent { message: "run tools".into(), images: None, @@ -1471,6 +1776,82 @@ mod tests { ); } + #[test] + fn drops_late_turn_scoped_item_for_unknown_turn_id() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-a".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "first".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-a".into(), + last_agent_message: None, + }), + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-b".into(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "second".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "exec-unknown-turn".into(), + process_id: Some("pid-42".into()), + turn_id: "turn-missing".into(), + command: vec!["echo".into(), "done".into()], + cwd: PathBuf::from("/tmp"), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done\n".into(), + stderr: String::new(), + aggregated_output: "done\n".into(), + exit_code: 0, + duration: Duration::from_millis(5), + formatted_output: "done\n".into(), + status: CoreExecCommandStatus::Completed, + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-b".into(), + last_agent_message: None, + }), + ]; + + let mut builder = ThreadHistoryBuilder::new(); + for event in &events { + builder.handle_event(event); + } + let turns = builder.finish(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].id, "turn-a"); + assert_eq!(turns[1].id, "turn-b"); + assert_eq!(turns[0].items.len(), 1); + assert_eq!(turns[1].items.len(), 1); + assert_eq!( + turns[1].items[0], + ThreadItem::UserMessage { + id: "item-2".into(), + content: vec![UserInput::Text { + text: "second".into(), + text_elements: Vec::new(), + }], + } + ); + } + #[test] fn late_turn_complete_does_not_close_active_turn() { let events = vec![ @@ -1572,7 +1953,7 @@ mod tests { assert_eq!(turns.len(), 2); assert_eq!(turns[0].id, "turn-a"); assert_eq!(turns[1].id, "turn-b"); - assert_eq!(turns[1].status, TurnStatus::Completed); + assert_eq!(turns[1].status, TurnStatus::InProgress); assert_eq!(turns[1].items.len(), 2); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 328fdc0912..6a1959bb25 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2727,6 +2727,26 @@ pub enum ThreadItem { ContextCompaction { id: String }, } +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type", rename_all = "camelCase")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b206eac355..2e87a16844 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -45,7 +45,6 @@ use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::PatchApplyStatus; -use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; @@ -72,6 +71,7 @@ use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::build_turns_from_rollout_items; +use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::parse_command::shlex_join; @@ -81,7 +81,6 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::FileChange as CoreFileChange; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; @@ -1706,46 +1705,6 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } -fn convert_patch_changes(changes: &HashMap) -> Vec { - let mut converted: Vec = changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_string_lossy().into_owned(), - kind: map_patch_change_kind(change), - diff: format_file_change_diff(change), - }) - .collect(); - converted.sort_by(|a, b| a.path.cmp(&b.path)); - converted -} - -fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind { - match change { - CoreFileChange::Add { .. } => V2PatchChangeKind::Add, - CoreFileChange::Delete { .. } => V2PatchChangeKind::Delete, - CoreFileChange::Update { move_path, .. } => V2PatchChangeKind::Update { - move_path: move_path.clone(), - }, - } -} - -fn format_file_change_diff(change: &CoreFileChange) -> String { - match change { - CoreFileChange::Add { content } => content.clone(), - CoreFileChange::Delete { content } => content.clone(), - CoreFileChange::Update { - unified_diff, - move_path, - } => { - if let Some(path) = move_path { - format!("{unified_diff}\n\nMoved to: {}", path.display()) - } else { - unified_diff.clone() - } - } - } -} - fn map_file_change_approval_decision( decision: FileChangeApprovalDecision, ) -> (ReviewDecision, Option) { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 58ca63306e..ea2701d635 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -336,6 +336,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + single_client_mode: bool, cli_overrides: Vec<(String, TomlValue)>, cloud_requirements: Arc>, active_login: Arc>>, @@ -361,6 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) config: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cloud_requirements: Arc>, + pub(crate) single_client_mode: bool, pub(crate) feedback: CodexFeedback, } @@ -397,6 +399,7 @@ impl CodexMessageProcessor { config, cli_overrides, cloud_requirements, + single_client_mode, feedback, } = args; Self { @@ -405,6 +408,7 @@ impl CodexMessageProcessor { outgoing: outgoing.clone(), codex_linux_sandbox_exe, config, + single_client_mode, cli_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), @@ -3042,21 +3046,14 @@ impl CodexMessageProcessor { return true; } - if let Err(err) = self - .ensure_conversation_listener( - existing_thread_id, - request_id.connection_id, - false, - ApiVersion::V2, - ) - .await - { - tracing::warn!( - "failed to attach listener for thread {}: {}", - existing_thread_id, - err.message - ); - } + let thread_state = self.thread_state_manager.thread_state(existing_thread_id); + self.ensure_listener_task_running( + existing_thread_id, + existing_thread.clone(), + thread_state.clone(), + ApiVersion::V2, + ) + .await; let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); @@ -3068,41 +3065,39 @@ impl CodexMessageProcessor { ); } - let Some(mut thread) = self - .load_thread_from_rollout_or_send_internal( - request_id.clone(), - existing_thread_id, - rollout_path.as_path(), - config_snapshot.model_provider_id.as_str(), - ) - .await - else { + let listener_command_tx = { + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + let err = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" + ), + data: None, + }; + self.outgoing.send_error(request_id, err).await; return true; }; - let ThreadConfigSnapshot { - model, - model_provider_id, - approval_policy, - sandbox_policy, - cwd, - reasoning_effort, - .. - } = config_snapshot; - thread.status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - let response = ThreadResumeResponse { - thread, - model, - model_provider: model_provider_id, - cwd, - approval_policy: approval_policy.into(), - sandbox: sandbox_policy.into(), - reasoning_effort, - }; - self.outgoing.send_response(request_id, response).await; + let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + crate::thread_state::PendingThreadResumeRequest { + request_id: request_id.clone(), + rollout_path, + config_snapshot, + }, + ); + if listener_command_tx.send(command).is_err() { + let err = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" + ), + data: None, + }; + self.outgoing.send_error(request_id, err).await; + } return true; } false @@ -5817,17 +5812,18 @@ impl CodexMessageProcessor { api_version: ApiVersion, ) { let (cancel_tx, mut cancel_rx) = oneshot::channel(); - { + let mut listener_command_rx = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return; } - thread_state.set_listener(cancel_tx, &conversation); - } + thread_state.set_listener(cancel_tx, &conversation) + }; let outgoing_for_task = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); let thread_watch_manager = self.thread_watch_manager.clone(); let fallback_model_provider = self.config.model_provider_id.clone(); + let single_client_mode = self.single_client_mode; tokio::spawn(async move { loop { tokio::select! { @@ -5869,7 +5865,10 @@ impl CodexMessageProcessor { conversation_id.to_string().into(), ); let (subscribed_connection_ids, raw_events_enabled) = { - let thread_state = thread_state.lock().await; + let mut thread_state = thread_state.lock().await; + if !single_client_mode { + thread_state.track_current_turn_event(&event.msg); + } ( thread_state.subscribed_connection_ids(), thread_state.experimental_raw_events, @@ -5908,6 +5907,25 @@ impl CodexMessageProcessor { ) .await; } + listener_command = listener_command_rx.recv() => { + let Some(listener_command) = listener_command else { + break; + }; + match listener_command { + crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + resume_request, + ) => { + handle_pending_thread_resume_request( + conversation_id, + &thread_state, + &thread_watch_manager, + &outgoing_for_task, + resume_request, + ) + .await; + } + } + } } } }); @@ -6206,6 +6224,106 @@ impl CodexMessageProcessor { } } +async fn handle_pending_thread_resume_request( + conversation_id: ThreadId, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending: crate::thread_state::PendingThreadResumeRequest, +) { + let active_turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot() + }; + + let request_id = pending.request_id; + let connection_id = request_id.connection_id; + let mut thread = match load_thread_for_running_resume_response( + conversation_id, + pending.rollout_path.as_path(), + pending.config_snapshot.model_provider_id.as_str(), + active_turn.as_ref(), + ) + .await + { + Ok(thread) => thread, + Err(message) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message, + data: None, + }, + ) + .await; + return; + } + }; + thread.status = thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + let ThreadConfigSnapshot { + model, + model_provider_id, + approval_policy, + sandbox_policy, + cwd, + reasoning_effort, + .. + } = pending.config_snapshot; + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + cwd, + approval_policy: approval_policy.into(), + sandbox: sandbox_policy.into(), + reasoning_effort, + }; + outgoing.send_response(request_id, response).await; + thread_state.lock().await.add_connection(connection_id); +} + +async fn load_thread_for_running_resume_response( + conversation_id: ThreadId, + rollout_path: &Path, + fallback_provider: &str, + active_turn: Option<&Turn>, +) -> std::result::Result { + let mut thread = read_summary_from_rollout(rollout_path, fallback_provider) + .await + .map(summary_to_thread) + .map_err(|err| { + format!( + "failed to load rollout `{}` for thread {conversation_id}: {err}", + rollout_path.display() + ) + })?; + + let mut turns = read_rollout_items_from_rollout(rollout_path) + .await + .map(|items| build_turns_from_rollout_items(&items)) + .map_err(|err| { + format!( + "failed to load rollout `{}` for thread {conversation_id}: {err}", + rollout_path.display() + ) + })?; + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); + } + thread.turns = turns; + Ok(thread) +} + +fn merge_turn_history_with_active_turn(turns: &mut Vec, active_turn: Turn) { + turns.retain(|turn| turn.id != active_turn.id); + turns.push(active_turn); +} + fn collect_resume_override_mismatches( request: &ThreadResumeParams, config_snapshot: &ThreadConfigSnapshot, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 7752ee92c7..8e7e1455c4 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -238,7 +238,8 @@ pub async fn run_main_with_transport( Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?); } } - let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio); + let single_client_mode = matches!(transport, AppServerTransport::Stdio); + let shutdown_when_no_connections = single_client_mode; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. @@ -439,6 +440,7 @@ pub async fn run_main_with_transport( outgoing: outgoing_message_sender, codex_linux_sandbox_exe, config: Arc::new(config), + single_client_mode, cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f1c6029a21..e80e1790f8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -139,6 +139,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) config: Arc, + pub(crate) single_client_mode: bool, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, @@ -154,6 +155,7 @@ impl MessageProcessor { outgoing, codex_linux_sandbox_exe, config, + single_client_mode, cli_overrides, loader_overrides, cloud_requirements, @@ -184,6 +186,7 @@ impl MessageProcessor { config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), cloud_requirements: cloud_requirements.clone(), + single_client_mode, feedback, }); let config_api = ConfigApi::new( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index fd0f58abd1..264c9a39d6 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,13 +1,19 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; +use codex_core::ThreadConfigSnapshot; use codex_protocol::ThreadId; +use codex_protocol::protocol::EventMsg; use std::collections::HashMap; use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::Mutex; +use tokio::sync::mpsc; use tokio::sync::oneshot; use uuid::Uuid; @@ -16,6 +22,16 @@ type PendingInterruptQueue = Vec<( crate::codex_message_processor::ApiVersion, )>; +pub(crate) struct PendingThreadResumeRequest { + pub(crate) request_id: ConnectionRequestId, + pub(crate) rollout_path: PathBuf, + pub(crate) config_snapshot: ThreadConfigSnapshot, +} + +pub(crate) enum ThreadListenerCommand { + SendThreadResumeResponse(PendingThreadResumeRequest), +} + /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { @@ -31,6 +47,8 @@ pub(crate) struct ThreadState { pub(crate) turn_summary: TurnSummary, pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, + listener_command_tx: Option>, + current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, subscribed_connections: HashSet, } @@ -47,17 +65,22 @@ impl ThreadState { &mut self, cancel_tx: oneshot::Sender<()>, conversation: &Arc, - ) { + ) -> mpsc::UnboundedReceiver { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } + let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); + self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); + listener_command_rx } pub(crate) fn clear_listener(&mut self) { if let Some(cancel_tx) = self.cancel_tx.take() { let _ = cancel_tx.send(()); } + self.listener_command_tx = None; + self.current_turn_history.reset(); self.listener_thread = None; } @@ -76,6 +99,23 @@ impl ThreadState { pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) { self.experimental_raw_events = enabled; } + + pub(crate) fn listener_command_tx( + &self, + ) -> Option> { + self.listener_command_tx.clone() + } + + pub(crate) fn active_turn_snapshot(&self) -> Option { + self.current_turn_history.active_turn_snapshot() + } + + pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + self.current_turn_history.handle_event(event); + if !self.current_turn_history.has_active_turn() { + self.current_turn_history.reset(); + } + } } #[derive(Clone, Copy)] @@ -200,12 +240,24 @@ impl ThreadStateManager { } pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) { - let Some(thread_ids) = self.thread_ids_by_connection.remove(&connection_id) else { - return; - }; + let thread_ids = self + .thread_ids_by_connection + .remove(&connection_id) + .unwrap_or_default(); self.subscription_state_by_id .retain(|_, state| state.connection_id != connection_id); + if thread_ids.is_empty() { + for thread_state in self.thread_states.values() { + let mut thread_state = thread_state.lock().await; + thread_state.remove_connection(connection_id); + if thread_state.subscribed_connection_ids().is_empty() { + thread_state.clear_listener(); + } + } + return; + } + for thread_id in thread_ids { if let Some(thread_state) = self.thread_states.get(&thread_id) { let mut thread_state = thread_state.lock().await; From 18bd6d2d71f0055d08ee9856de469fcfd28899cf Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 19 Feb 2026 22:06:51 -0800 Subject: [PATCH 04/39] [apps] Store apps tool cache in disk to reduce startup time. (#11822) We now write MCP tools from installed apps to disk cache so that they can be picked up instantly at startup. We still do a fresh fetch from remote MCP server but it's non blocking unless there's a cache miss. - [x] Store apps tool cache in disk to reduce startup time. --- codex-rs/chatgpt/src/connectors.rs | 64 +- codex-rs/core/src/codex.rs | 5 + codex-rs/core/src/connectors.rs | 194 ++++- codex-rs/core/src/mcp/mod.rs | 3 + codex-rs/core/src/mcp_connection_manager.rs | 745 +++++++++++++++----- codex-rs/tui/src/chatwidget.rs | 44 +- codex-rs/tui/src/chatwidget/tests.rs | 37 + 7 files changed, 848 insertions(+), 244 deletions(-) diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 6dc2161f0c..27f2f1de94 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -4,8 +4,6 @@ use std::sync::LazyLock; use std::sync::Mutex as StdMutex; use codex_core::config::Config; -use codex_core::default_client::is_first_party_chat_originator; -use codex_core::default_client::originator; use codex_core::features::Feature; use codex_core::token_data::TokenData; use serde::Deserialize; @@ -22,8 +20,10 @@ use codex_core::connectors::AppMetadata; use codex_core::connectors::CONNECTORS_CACHE_TTL; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; +use codex_core::connectors::filter_disallowed_connectors; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; +pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; use codex_core::connectors::merge_connectors; pub use codex_core::connectors::with_app_enabled_state; @@ -106,7 +106,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let token_data = get_chatgpt_token_data()?; let cache_key = all_connectors_cache_key(config, &token_data); - read_cached_all_connectors(&cache_key) + read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors) } pub async fn list_all_connectors_with_options( @@ -123,7 +123,7 @@ pub async fn list_all_connectors_with_options( get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; let cache_key = all_connectors_cache_key(config, &token_data); if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) { - return Ok(cached_connectors); + return Ok(filter_disallowed_connectors(cached_connectors)); } let mut apps = list_directory_connectors(config).await?; @@ -149,6 +149,7 @@ pub async fn list_all_connectors_with_options( .cmp(&right.name) .then_with(|| left.id.cmp(&right.id)) }); + let connectors = filter_disallowed_connectors(connectors); write_cached_all_connectors(cache_key, &connectors); Ok(connectors) } @@ -453,45 +454,6 @@ fn normalize_connector_value(value: Option<&str>) -> Option { .filter(|value| !value.is_empty()) .map(str::to_string) } - -const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ - "asdk_app_6938a94a61d881918ef32cb999ff937c", - "connector_2b0a9009c9c64bf9933a3dae3f2b1254", - "connector_68de829bf7648191acd70a907364c67c", - "connector_68e004f14af881919eb50893d3d9f523", - "connector_69272cb413a081919685ec3c88d1744e", - "connector_0f9c9d4592e54d0a9a12b3f44a1e2010", -]; -const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = - &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; -const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; - -fn filter_disallowed_connectors(connectors: Vec) -> Vec { - filter_disallowed_connectors_for_originator(connectors, originator().value.as_str()) -} - -fn filter_disallowed_connectors_for_originator( - connectors: Vec, - originator_value: &str, -) -> Vec { - let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { - FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS - } else { - DISALLOWED_CONNECTOR_IDS - }; - - connectors - .into_iter() - .filter(|connector| is_connector_allowed(connector, disallowed_connector_ids)) - .collect() -} - -fn is_connector_allowed(connector: &AppInfo, disallowed_connector_ids: &[&str]) -> bool { - let connector_id = connector.id.as_str(); - !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) - && !disallowed_connector_ids.contains(&connector_id) -} - #[cfg(test)] mod tests { use super::*; @@ -554,22 +516,6 @@ mod tests { assert_eq!(filtered, vec![app("delta")]); } - #[test] - fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { - let filtered = filter_disallowed_connectors_for_originator( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),] - ); - } - fn merged_app(id: &str, is_accessible: bool) -> AppInfo { AppInfo { id: id.to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d9ba1ca3f6..a384ffc00f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -149,6 +149,7 @@ use crate::mcp::effective_mcp_servers; use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only; use crate::mcp_connection_manager::filter_mcp_tools_by_name; use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; @@ -1420,6 +1421,8 @@ impl Session { &session_configuration.approval_policy, tx_event.clone(), sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth), ) .await; { @@ -3068,6 +3071,8 @@ impl Session { &turn_context.config.permissions.approval_policy, self.get_tx_event(), sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 884e32dd17..a34477850d 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -19,15 +19,18 @@ use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; use crate::config::types::AppsConfigToml; +use crate::default_client::is_first_party_chat_originator; +use crate::default_client::originator; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; -use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); +const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); #[derive(Clone, PartialEq, Eq)] struct AccessibleConnectorsCacheKey { @@ -47,10 +50,20 @@ struct CachedAccessibleConnectors { static ACCESSIBLE_CONNECTORS_CACHE: LazyLock>> = LazyLock::new(|| StdMutex::new(None)); +#[derive(Debug, Clone)] +pub struct AccessibleConnectorsStatus { + pub connectors: Vec, + pub codex_apps_ready: bool, +} + pub async fn list_accessible_connectors_from_mcp_tools( config: &Config, ) -> anyhow::Result> { - list_accessible_connectors_from_mcp_tools_with_options(config, false).await + Ok( + list_accessible_connectors_from_mcp_tools_with_options_and_status(config, false) + .await? + .connectors, + ) } pub async fn list_cached_accessible_connectors_from_mcp_tools( @@ -63,15 +76,29 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( let auth_manager = auth_manager_from_config(config); let auth = auth_manager.auth().await; let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); - read_cached_accessible_connectors(&cache_key) + read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors) } pub async fn list_accessible_connectors_from_mcp_tools_with_options( config: &Config, force_refetch: bool, ) -> anyhow::Result> { + Ok( + list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch) + .await? + .connectors, + ) +} + +pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( + config: &Config, + force_refetch: bool, +) -> anyhow::Result { if !config.features.enabled(Feature::Apps) { - return Ok(Vec::new()); + return Ok(AccessibleConnectorsStatus { + connectors: Vec::new(), + codex_apps_ready: true, + }); } let auth_manager = auth_manager_from_config(config); @@ -79,12 +106,19 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { - return Ok(cached_connectors); + let cached_connectors = filter_disallowed_connectors(cached_connectors); + return Ok(AccessibleConnectorsStatus { + connectors: cached_connectors, + codex_apps_ready: true, + }); } let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref(), config); if mcp_servers.is_empty() { - return Ok(Vec::new()); + return Ok(AccessibleConnectorsStatus { + connectors: Vec::new(), + codex_apps_ready: true, + }); } let auth_status_entries = @@ -107,6 +141,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( &config.permissions.approval_policy, tx_event, sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; @@ -120,23 +156,45 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options( ); } + let mut tools = mcp_connection_manager.list_all_tools().await; + let mut should_reload_tools = false; let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { - let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); - mcp_connection_manager - .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) - .await + let immediate_ready = mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO) + .await; + if immediate_ready { + true + } else if tools.is_empty() { + let timeout = cfg + .startup_timeout_sec + .unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS); + let ready = mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) + .await; + should_reload_tools = ready; + ready + } else { + false + } } else { false }; + if should_reload_tools { + tools = mcp_connection_manager.list_all_tools().await; + } + if codex_apps_ready { + cancel_token.cancel(); + } - let tools = mcp_connection_manager.list_all_tools().await; - cancel_token.cancel(); - - let accessible_connectors = accessible_connectors_from_mcp_tools(&tools); + let accessible_connectors = + filter_disallowed_connectors(accessible_connectors_from_mcp_tools(&tools)); if codex_apps_ready || !accessible_connectors.is_empty() { write_cached_accessible_connectors(cache_key, &accessible_connectors); } - Ok(accessible_connectors) + Ok(AccessibleConnectorsStatus { + connectors: accessible_connectors, + codex_apps_ready, + }) } fn accessible_connectors_cache_key( @@ -288,6 +346,48 @@ pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> connectors } +const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ + "asdk_app_6938a94a61d881918ef32cb999ff937c", + "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + "connector_68de829bf7648191acd70a907364c67c", + "connector_68e004f14af881919eb50893d3d9f523", + "connector_69272cb413a081919685ec3c88d1744e", +]; +const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = + &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; +const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; + +pub fn filter_disallowed_connectors(connectors: Vec) -> Vec { + filter_disallowed_connectors_for_originator(connectors, originator().value.as_str()) +} + +pub(crate) fn is_connector_id_allowed(connector_id: &str) -> bool { + is_connector_id_allowed_for_originator(connector_id, originator().value.as_str()) +} + +fn filter_disallowed_connectors_for_originator( + connectors: Vec, + originator_value: &str, +) -> Vec { + connectors + .into_iter() + .filter(|connector| { + is_connector_id_allowed_for_originator(connector.id.as_str(), originator_value) + }) + .collect() +} + +fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool { + let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) { + FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS + } else { + DISALLOWED_CONNECTOR_IDS + }; + + !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) + && !disallowed_connector_ids.contains(&connector_id) +} + fn read_apps_config(config: &Config) -> Option { let effective_config = config.config_layer_stack.effective_config(); let apps_config = effective_config.as_table()?.get("apps")?.clone(); @@ -368,3 +468,67 @@ pub fn connector_name_slug(name: &str) -> String { fn format_connector_label(name: &str, _id: &str) -> String { name.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + branding: None, + app_metadata: None, + labels: None, + is_accessible: false, + is_enabled: true, + } + } + + #[test] + fn filter_disallowed_connectors_allows_non_disallowed_connectors() { + let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); + assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); + } + + #[test] + fn filter_disallowed_connectors_filters_openai_prefix() { + let filtered = filter_disallowed_connectors(vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ]); + assert_eq!(filtered, vec![app("gamma")]); + } + + #[test] + fn filter_disallowed_connectors_filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("delta"), + ]); + assert_eq!(filtered, vec![app("delta")]); + } + + #[test] + fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { + let filtered = filter_disallowed_connectors_for_originator( + vec![ + app("connector_openai_foo"), + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), + ], + "codex_atlas", + ); + assert_eq!( + filtered, + vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),] + ); + } +} diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 681b1674b6..f2c2557514 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -24,6 +24,7 @@ use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; +use crate::mcp_connection_manager::codex_apps_tools_cache_key; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; @@ -208,6 +209,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent &config.permissions.approval_policy, tx_event, sandbox_state, + config.codex_home.clone(), + codex_apps_tools_cache_key(auth.as_ref()), ) .await; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index c872a9732f..c76e19809f 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -12,8 +12,9 @@ use std::env; use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; -use std::sync::LazyLock; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -76,6 +77,7 @@ use tracing::warn; use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; +use crate::connectors::is_connector_id_allowed; /// Delimiter used to separate the server name from the tool name in a fully /// qualified tool name. @@ -91,7 +93,8 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); /// Default timeout for individual tool calls. const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); -const CODEX_APPS_TOOLS_CACHE_TTL: Duration = Duration::from_secs(3600); +const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 1; +const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; @@ -123,6 +126,27 @@ fn sha1_hex(s: &str) -> String { format!("{sha1:x}") } +pub(crate) fn codex_apps_tools_cache_key( + auth: Option<&crate::CodexAuth>, +) -> CodexAppsToolsCacheKey { + let token_data = auth.and_then(|auth| auth.get_token_data().ok()); + let account_id = token_data + .as_ref() + .and_then(|token_data| token_data.account_id.clone()); + let chatgpt_user_id = token_data + .as_ref() + .and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()); + let is_workspace_account = token_data + .as_ref() + .is_some_and(|token_data| token_data.id_token.is_workspace_account()); + + CodexAppsToolsCacheKey { + account_id, + chatgpt_user_id, + is_workspace_account, + } +} + fn qualify_tools(tools: I) -> HashMap where I: IntoIterator, @@ -165,7 +189,7 @@ where qualified_tools } -#[derive(Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ToolInfo { pub(crate) server_name: String, pub(crate) tool_name: String, @@ -174,14 +198,40 @@ pub(crate) struct ToolInfo { pub(crate) connector_name: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CodexAppsToolsCacheKey { + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + #[derive(Clone)] -struct CachedCodexAppsTools { - expires_at: Instant, +struct CodexAppsToolsCacheContext { + codex_home: PathBuf, + user_key: CodexAppsToolsCacheKey, +} + +impl CodexAppsToolsCacheContext { + fn cache_path(&self) -> PathBuf { + let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); + let user_key_hash = sha1_hex(&user_key_json); + self.codex_home + .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(format!("{user_key_hash}.json")) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsToolsDiskCache { + schema_version: u8, tools: Vec, } -static CODEX_APPS_TOOLS_CACHE: LazyLock>> = - LazyLock::new(|| StdMutex::new(None)); +enum CachedCodexAppsToolsLoad { + Hit(Vec), + Missing, + Invalid, +} type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; @@ -289,9 +339,35 @@ struct ManagedClient { tool_filter: ToolFilter, tool_timeout: Option, server_supports_sandbox_state_capability: bool, + codex_apps_tools_cache_context: Option, } impl ManagedClient { + fn listed_tools(&self) -> Vec { + let total_start = Instant::now(); + if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() + && let CachedCodexAppsToolsLoad::Hit(tools) = + load_cached_codex_apps_tools(cache_context) + { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); + return filter_tools(tools, &self.tool_filter); + } + + if self.codex_apps_tools_cache_context.is_some() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); + } + + self.tools.clone() + } + /// Returns once the server has ack'd the sandbox state update. async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { if !self.server_supports_sandbox_state_capability { @@ -312,6 +388,8 @@ impl ManagedClient { #[derive(Clone)] struct AsyncManagedClient { client: Shared>>, + startup_snapshot: Option>, + startup_complete: Arc, } impl AsyncManagedClient { @@ -322,33 +400,63 @@ impl AsyncManagedClient { cancel_token: CancellationToken, tx_event: Sender, elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, ) -> Self { let tool_filter = ToolFilter::from_config(&config); + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ) + .map(|tools| filter_tools(tools, &tool_filter)); + let startup_tool_filter = tool_filter; + let startup_complete = Arc::new(AtomicBool::new(false)); + let startup_complete_for_fut = Arc::clone(&startup_complete); let fut = async move { - if let Err(error) = validate_mcp_server_name(&server_name) { - return Err(error.into()); - } + let outcome = async { + if let Err(error) = validate_mcp_server_name(&server_name) { + return Err(error.into()); + } - let client = - Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); - match start_server_task( - server_name, - client, - config.startup_timeout_sec.or(Some(DEFAULT_STARTUP_TIMEOUT)), - config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), - tool_filter, - tx_event, - elicitation_requests, - ) - .or_cancel(&cancel_token) - .await - { - Ok(result) => result, - Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + let client = + Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?); + match start_server_task( + server_name, + client, + StartServerTaskParams { + startup_timeout: config + .startup_timeout_sec + .or(Some(DEFAULT_STARTUP_TIMEOUT)), + tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), + tool_filter: startup_tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + }, + ) + .or_cancel(&cancel_token) + .await + { + Ok(result) => result, + Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + } } + .await; + + startup_complete_for_fut.store(true, Ordering::Release); + outcome }; + let client = fut.boxed().shared(); + if startup_snapshot.is_some() { + let startup_task = client.clone(); + tokio::spawn(async move { + let _ = startup_task.await; + }); + } + Self { - client: fut.boxed().shared(), + client, + startup_snapshot, + startup_complete, } } @@ -356,6 +464,24 @@ impl AsyncManagedClient { self.client.clone().await } + fn startup_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.startup_snapshot.clone(); + } + None + } + + async fn listed_tools(&self) -> Option> { + if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + return Some(startup_tools); + } + + match self.client().await { + Ok(client) => Some(client.listed_tools()), + Err(_) => self.startup_snapshot.clone(), + } + } + async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> { let managed = self.client().await?; managed.notify_sandbox_state_change(sandbox_state).await @@ -417,6 +543,8 @@ impl McpConnectionManager { approval_policy: &Constrained, tx_event: Sender, initial_sandbox_state: SandboxState, + codex_home: PathBuf, + codex_apps_tools_cache_key: CodexAppsToolsCacheKey, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); @@ -433,6 +561,14 @@ impl McpConnectionManager { }, ) .await; + let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.clone(), + user_key: codex_apps_tools_cache_key.clone(), + }) + } else { + None + }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), cfg, @@ -440,6 +576,7 @@ impl McpConnectionManager { cancel_token.clone(), tx_event.clone(), elicitation_requests.clone(), + codex_apps_tools_cache_context, ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -575,29 +712,11 @@ impl McpConnectionManager { #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); - for (server_name, managed_client) in &self.clients { - let client = managed_client.client().await.ok(); - if let Some(client) = client { - let rmcp_client = client.client; - let tool_timeout = client.tool_timeout; - let tool_filter = client.tool_filter; - let mut server_tools = client.tools; - - if server_name == CODEX_APPS_MCP_SERVER_NAME { - match list_tools_for_client(server_name, &rmcp_client, tool_timeout).await { - Ok(fresh_or_cached_tools) => { - server_tools = fresh_or_cached_tools; - } - Err(err) => { - warn!( - "Failed to refresh tools for MCP server '{server_name}', using startup snapshot: {err:#}" - ); - } - } - } - - tools.extend(qualify_tools(filter_tools(server_tools, tool_filter))); - } + for managed_client in self.clients.values() { + let Some(server_tools) = managed_client.listed_tools().await else { + continue; + }; + tools.extend(qualify_tools(server_tools)); } tools } @@ -615,6 +734,8 @@ impl McpConnectionManager { .await .context("failed to get client")?; + let list_start = Instant::now(); + let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, &managed_client.client, @@ -624,8 +745,22 @@ impl McpConnectionManager { .with_context(|| { format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") })?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); - write_cached_codex_apps_tools(&tools); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + managed_client.codex_apps_tools_cache_context.as_ref(), + &tools, + ); + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); Ok(()) } @@ -934,7 +1069,7 @@ impl ToolFilter { } } -fn filter_tools(tools: Vec, filter: ToolFilter) -> Vec { +fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { tools .into_iter() .filter(|tool| filter.allows(&tool.tool_name)) @@ -1077,14 +1212,17 @@ fn elicitation_capability_for_server(server_name: &str) -> Option, - startup_timeout: Option, // TODO: cancel_token should handle this. - tool_timeout: Duration, - tool_filter: ToolFilter, - tx_event: Sender, - elicitation_requests: ElicitationRequestManager, + params: StartServerTaskParams, ) -> Result { + let StartServerTaskParams { + startup_timeout, + tool_timeout, + tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + } = params; let elicitation = elicitation_capability_for_server(&server_name); - let params = InitializeRequestParams { meta: None, capabilities: ClientCapabilities { @@ -1113,9 +1251,29 @@ async fn start_server_task( .await .map_err(StartupOutcomeError::from)?; - let tools = list_tools_for_client(&server_name, &client, startup_timeout) + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached(&server_name, &client, startup_timeout) .await .map_err(StartupOutcomeError::from)?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + write_cached_codex_apps_tools_if_needed( + &server_name, + codex_apps_tools_cache_context.as_ref(), + &tools, + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + } + let tools = filter_tools(tools, &tool_filter); let server_supports_sandbox_state_capability = initialize_result .capabilities @@ -1130,11 +1288,21 @@ async fn start_server_task( tool_timeout: Some(tool_timeout), tool_filter, server_supports_sandbox_state_capability, + codex_apps_tools_cache_context, }; Ok(managed) } +struct StartServerTaskParams { + startup_timeout: Option, // TODO: cancel_token should handle this. + tool_timeout: Duration, + tool_filter: ToolFilter, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, +} + async fn make_rmcp_client( server_name: &str, transport: McpServerTransportConfig, @@ -1179,80 +1347,99 @@ async fn make_rmcp_client( } } -async fn list_tools_for_client( +fn write_cached_codex_apps_tools_if_needed( server_name: &str, - client: &Arc, - timeout: Option, -) -> Result> { - let total_start = Instant::now(); - if server_name == CODEX_APPS_MCP_SERVER_NAME - && let Some(cached_tools) = read_cached_codex_apps_tools() - { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "hit")], - ); - return Ok(cached_tools); + cache_context: Option<&CodexAppsToolsCacheContext>, + tools: &[ToolInfo], +) { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return; } - let fetch_start = Instant::now(); - let tools = list_tools_for_client_uncached(server_name, client, timeout).await?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); - - if server_name == CODEX_APPS_MCP_SERVER_NAME { + if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); - write_cached_codex_apps_tools(&tools); + write_cached_codex_apps_tools(cache_context, tools); emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), &[], ); } - - if server_name == CODEX_APPS_MCP_SERVER_NAME { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "miss")], - ); - } - Ok(tools) } -fn read_cached_codex_apps_tools() -> Option> { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let now = Instant::now(); - - if let Some(cached) = cache_guard.as_ref() - && now < cached.expires_at - { - return Some(cached.tools.clone()); +fn load_startup_cached_codex_apps_tools_snapshot( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option> { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; } - if cache_guard - .as_ref() - .is_some_and(|cached| now >= cached.expires_at) - { - *cache_guard = None; + let cache_context = cache_context?; + + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, } - None } -fn write_cached_codex_apps_tools(tools: &[ToolInfo]) { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = Some(CachedCodexAppsTools { - expires_at: Instant::now() + CODEX_APPS_TOOLS_CACHE_TTL, - tools: tools.to_vec(), - }); +#[cfg(test)] +fn read_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> Option> { + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +fn load_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> CachedCodexAppsToolsLoad { + let cache_path = cache_context.cache_path(); + let bytes = match std::fs::read(cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return CachedCodexAppsToolsLoad::Missing; + } + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { + return CachedCodexAppsToolsLoad::Invalid; + } + CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) +} + +fn write_cached_codex_apps_tools(cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo]) { + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); + let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { + schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + tools, + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { + tools + .into_iter() + .filter(|tool| { + tool.connector_id + .as_deref() + .is_none_or(is_connector_id_allowed) + }) + .collect() } fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { @@ -1267,7 +1454,7 @@ async fn list_tools_for_client_uncached( timeout: Option, ) -> Result> { let resp = client.list_tools_with_connector_ids(None, timeout).await?; - Ok(resp + let tools = resp .tools .into_iter() .map(|tool| { @@ -1288,7 +1475,11 @@ async fn list_tools_for_client_uncached( connector_name, } }) - .collect()) + .collect(); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + return Ok(filter_disallowed_codex_apps_tools(tools)); + } + Ok(tools) } fn validate_mcp_server_name(server_name: &str) -> Result<()> { @@ -1376,6 +1567,7 @@ mod tests { use rmcp::model::JsonObject; use std::collections::HashSet; use std::sync::Arc; + use tempfile::tempdir; fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { ToolInfo { @@ -1397,19 +1589,31 @@ mod tests { } } - fn with_clean_codex_apps_tools_cache(f: impl FnOnce() -> T) -> T { - let previous_cache = { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache_guard.take() - }; - let result = f(); - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = previous_cache; - result + fn create_test_tool_with_connector( + server_name: &str, + tool_name: &str, + connector_id: &str, + connector_name: Option<&str>, + ) -> ToolInfo { + let mut tool = create_test_tool(server_name, tool_name); + tool.connector_id = Some(connector_id.to_string()); + tool.connector_name = connector_name.map(ToOwned::to_owned); + tool + } + + fn create_codex_apps_tools_cache_context( + codex_home: PathBuf, + account_id: Option<&str>, + chatgpt_user_id: Option<&str>, + ) -> CodexAppsToolsCacheContext { + CodexAppsToolsCacheContext { + codex_home, + user_key: CodexAppsToolsCacheKey { + account_id: account_id.map(ToOwned::to_owned), + chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), + is_workspace_account: false, + }, + } } #[test] @@ -1586,9 +1790,9 @@ mod tests { disabled: HashSet::from(["tool_a".to_string()]), }; - let filtered: Vec<_> = filter_tools(server1_tools, server1_filter) + let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter) .into_iter() - .chain(filter_tools(server2_tools, server2_filter)) + .chain(filter_tools(server2_tools, &server2_filter)) .collect(); assert_eq!(filtered.len(), 1); @@ -1598,43 +1802,260 @@ mod tests { #[test] fn codex_apps_tools_cache_is_overwritten_by_last_write() { - with_clean_codex_apps_tools_cache(|| { - let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; - let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - write_cached_codex_apps_tools(&tools_gateway_1); - let cached_gateway_1 = - read_cached_codex_apps_tools().expect("cache entry exists for first write"); - assert_eq!(cached_gateway_1[0].tool_name, "one"); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_1); + let cached_gateway_1 = read_cached_codex_apps_tools(&cache_context) + .expect("cache entry exists for first write"); + assert_eq!(cached_gateway_1[0].tool_name, "one"); - write_cached_codex_apps_tools(&tools_gateway_2); - let cached_gateway_2 = - read_cached_codex_apps_tools().expect("cache entry exists for second write"); - assert_eq!(cached_gateway_2[0].tool_name, "two"); - }); + write_cached_codex_apps_tools(&cache_context, &tools_gateway_2); + let cached_gateway_2 = read_cached_codex_apps_tools(&cache_context) + .expect("cache entry exists for second write"); + assert_eq!(cached_gateway_2[0].tool_name, "two"); } #[test] - fn codex_apps_tools_cache_is_cleared_when_expired() { - with_clean_codex_apps_tools_cache(|| { - let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale_tool")]; - write_cached_codex_apps_tools(&tools); + fn codex_apps_tools_cache_is_scoped_per_user() { + let codex_home = tempdir().expect("tempdir"); + let cache_context_user_1 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_context_user_2 = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-two"), + Some("user-two"), + ); + let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")]; + let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")]; - { - let mut cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache_guard.as_mut().expect("cache exists").expires_at = - Instant::now() - Duration::from_secs(1); - } + write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1); + write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2); - assert!(read_cached_codex_apps_tools().is_none()); + let read_user_1 = + read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one"); + let read_user_2 = + read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two"); - let cache_guard = CODEX_APPS_TOOLS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!(cache_guard.is_none()); - }); + assert_eq!(read_user_1[0].tool_name, "one"); + assert_eq!(read_user_2[0].tool_name, "two"); + assert_ne!( + cache_context_user_1.cache_path(), + cache_context_user_2.cache_path(), + "each user should get an isolated cache file" + ); + } + + #[test] + fn codex_apps_tools_cache_filters_disallowed_connectors() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let tools = vec![ + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "blocked_tool", + "connector_openai_hidden", + Some("Hidden"), + ), + create_test_tool_with_connector( + CODEX_APPS_MCP_SERVER_NAME, + "allowed_tool", + "calendar", + Some("Calendar"), + ), + ]; + + write_cached_codex_apps_tools(&cache_context, &tools); + let cached = + read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user"); + + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].tool_name, "allowed_tool"); + assert_eq!(cached[0].connector_id.as_deref(), Some("calendar")); + } + + #[test] + fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); + } + + #[test] + fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(cache_path, b"{not json").expect("write"); + + assert!(read_cached_codex_apps_tools(&cache_context).is_none()); + } + + #[test] + fn startup_cached_codex_apps_tools_loads_from_disk_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cached_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )]; + write_cached_codex_apps_tools(&cache_context, &cached_tools); + + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(startup_tools[0].tool_name, "calendar_search"); + } + + #[tokio::test] + async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); + } + + #[tokio::test] + async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + assert!(timeout_result.is_err()); + } + + #[tokio::test] + async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { + let pending_client = + futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(Vec::new()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }, + ); + + let timeout_result = + tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await; + let tools = timeout_result.expect("cache-hit startup snapshot should not block"); + assert!(tools.is_empty()); + } + + #[tokio::test] + async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { + let startup_tools = vec![create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + )]; + let failed_client = futures::future::ready::>( + Err(StartupOutcomeError::Failed { + error: "startup failed".to_string(), + }), + ) + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let mut manager = McpConnectionManager::new_uninitialized(&approval_policy); + let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: failed_client, + startup_snapshot: Some(startup_tools), + startup_complete, + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .get("mcp__codex_apps__calendar_create_event") + .expect("tool from startup cache"); + assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!(tool.tool_name, "calendar_create_event"); } #[test] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e746b4c57..7dcab8010a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -539,6 +539,7 @@ pub(crate) struct ChatWidget { mcp_startup_status: Option>, connectors_cache: ConnectorsCacheState, connectors_prefetch_in_flight: bool, + connectors_force_refetch_pending: bool, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -2650,6 +2651,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2813,6 +2815,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2965,6 +2968,7 @@ impl ChatWidget { mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4601,7 +4605,13 @@ impl ChatWidget { } fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { - if !self.connectors_enabled() || self.connectors_prefetch_in_flight { + if !self.connectors_enabled() { + return; + } + if self.connectors_prefetch_in_flight { + if force_refetch { + self.connectors_force_refetch_pending = true; + } return; } @@ -4613,8 +4623,8 @@ impl ChatWidget { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let accessible_connectors = - match connectors::list_accessible_connectors_from_mcp_tools_with_options( + let accessible_result = + match connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( &config, force_refetch, ) @@ -4629,6 +4639,9 @@ impl ChatWidget { return; } }; + let should_schedule_force_refetch = + !force_refetch && !accessible_result.codex_apps_ready; + let accessible_connectors = accessible_result.connectors; app_event_tx.send(AppEvent::ConnectorsLoaded { result: Ok(ConnectorsSnapshot { @@ -4638,7 +4651,8 @@ impl ChatWidget { }); let result: Result = async { - let all_connectors = connectors::list_all_connectors(&config).await?; + let all_connectors = + connectors::list_all_connectors_with_options(&config, force_refetch).await?; let connectors = connectors::merge_connectors_with_accessible( all_connectors, accessible_connectors, @@ -4653,6 +4667,12 @@ impl ChatWidget { result, is_final: true, }); + + if should_schedule_force_refetch { + app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } }); } @@ -6821,8 +6841,13 @@ impl ChatWidget { result: Result, is_final: bool, ) { + let mut trigger_pending_force_refetch = false; if is_final { self.connectors_prefetch_in_flight = false; + if self.connectors_force_refetch_pending { + self.connectors_force_refetch_pending = false; + trigger_pending_force_refetch = true; + } } match result { @@ -6857,13 +6882,16 @@ impl ChatWidget { Err(err) => { if matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) { warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); - return; + } else { + self.connectors_cache = ConnectorsCacheState::Failed(err); + self.bottom_pane.set_connectors_snapshot(None); } - - self.connectors_cache = ConnectorsCacheState::Failed(err); - self.bottom_pane.set_connectors_snapshot(None); } } + + if trigger_pending_force_refetch { + self.prefetch_connectors_with_options(true); + } } pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 829c39d0a0..d6d2ab1ad7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1627,6 +1627,7 @@ async fn make_chatwidget_manual( mcp_startup_status: None, connectors_cache: ConnectorsCacheState::default(), connectors_prefetch_in_flight: false, + connectors_force_refetch_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -4582,6 +4583,42 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { ); } +#[tokio::test] +async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetch() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + chat.connectors_prefetch_in_flight = true; + chat.connectors_force_refetch_pending = true; + + let full_connectors = vec![codex_chatgpt::connectors::AppInfo { + id: "unit_test_apps_refresh_failure_pending_connector".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }]; + chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { + connectors: full_connectors.clone(), + }); + + chat.on_connectors_loaded(Err("failed to load apps".to_string()), true); + + assert!(chat.connectors_prefetch_in_flight); + assert!(!chat.connectors_force_refetch_pending); + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors + ); +} + #[tokio::test] async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; From e747a8eb745a2eaa8c9f3a181fc5b35bed809d2b Mon Sep 17 00:00:00 2001 From: zuxin-oai Date: Fri, 20 Feb 2026 01:13:35 -0800 Subject: [PATCH 05/39] memories: add rollout_summary_file header to raw memories and tune prompts (#12221) ## Summary - Add `rollout_summary_file: .md` to each thread header in `raw_memories.md` so Phase 2 can reliably reference the canonical rollout summary filename. - Update the memory prompts/templates (`stage_one_system`, `consolidation`, `read_path`) for the new task-oriented raw-memory / MEMORY.md schema and stronger consolidation guidance. ## Details - `codex-rs/core/src/memories/storage.rs` - Writes the generated `rollout_summary_file` path into the per-thread metadata header when rebuilding `raw_memories.md`. - `codex-rs/core/src/memories/tests.rs` - Verifies the canonical `rollout_summary_file` header is present and ordered after `updated_at`/`cwd` in `raw_memories.md`. - Verifies task-structured raw-memory content is preserved while the canonical header is added. - `codex-rs/core/templates/memories/*.md` - Updates the stage-1 raw-memory format to task-grouped sections (`task`, `task_group`, `task_outcome`). - Updates Phase 2 consolidation guidance around recency (`updated_at`), task-oriented `MEMORY.md` blocks, and richer evidence-backed consolidation. - Tweaks the quick memory pass wording to emphasize topics/workflows in addition to keywords. ## Testing - `cargo test -p codex-core memories` --- codex-rs/core/src/memories/storage.rs | 96 +------- codex-rs/core/src/memories/tests.rs | 45 +++- .../core/templates/memories/consolidation.md | 217 ++++++++++++++---- codex-rs/core/templates/memories/read_path.md | 4 +- .../templates/memories/stage_one_system.md | 89 ++++++- 5 files changed, 298 insertions(+), 153 deletions(-) diff --git a/codex-rs/core/src/memories/storage.rs b/codex-rs/core/src/memories/storage.rs index ea2b00acf5..1a07dbe9b8 100644 --- a/codex-rs/core/src/memories/storage.rs +++ b/codex-rs/core/src/memories/storage.rs @@ -82,11 +82,11 @@ async fn rebuild_raw_memories_file( ) .map_err(raw_memories_format_error)?; writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?; - writeln!(body).map_err(raw_memories_format_error)?; let rollout_summary_file = format!("{}.md", rollout_summary_file_stem(memory)); - let raw_memory = - replace_rollout_summary_file_in_raw_memory(&memory.raw_memory, &rollout_summary_file); - body.push_str(raw_memory.trim()); + writeln!(body, "rollout_summary_file: {rollout_summary_file}") + .map_err(raw_memories_format_error)?; + writeln!(body).map_err(raw_memories_format_error)?; + body.push_str(memory.raw_memory.trim()); body.push_str("\n\n"); } @@ -161,26 +161,6 @@ fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error { std::io::Error::other(format!("format rollout summary: {err}")) } -fn replace_rollout_summary_file_in_raw_memory( - raw_memory: &str, - rollout_summary_file: &str, -) -> String { - const ROLLOUT_SUMMARY_PREFIX: &str = "rollout_summary_file: "; - - let replacement = format!("rollout_summary_file: {rollout_summary_file}"); - raw_memory - .split('\n') - .map(|line| { - if line.starts_with(ROLLOUT_SUMMARY_PREFIX) { - replacement.as_str() - } else { - line - } - }) - .collect::>() - .join("\n") -} - pub(crate) fn rollout_summary_file_stem(memory: &Stage1Output) -> String { rollout_summary_file_stem_from_parts( memory.thread_id, @@ -270,7 +250,6 @@ pub(super) fn rollout_summary_file_stem_from_parts( #[cfg(test)] mod tests { - use super::replace_rollout_summary_file_in_raw_memory; use super::rollout_summary_file_stem; use super::rollout_summary_file_stem_from_parts; use chrono::TimeZone; @@ -339,71 +318,4 @@ mod tests { assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX); } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_replaces_existing_value() { - let raw_memory = "\ ---- -rollout_summary_file: wrong.md -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - - assert_eq!( - normalized, - "\ ---- -rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md -description: demo -keywords: one, two ---- -- body line" - ); - } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_replaces_placeholder() { - let raw_memory = "\ ---- -rollout_summary_file: -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - - assert_eq!( - normalized, - "\ ---- -rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md -description: demo -keywords: one, two ---- -- body line" - ); - } - - #[test] - fn replace_rollout_summary_file_in_raw_memory_leaves_text_without_field_unchanged() { - let raw_memory = "\ ---- -description: demo -keywords: one, two ---- -- body line"; - let normalized = replace_rollout_summary_file_in_raw_memory( - raw_memory, - "2025-01-01T00-00-00-abcd-demo.md", - ); - assert_eq!(normalized, raw_memory); - } } diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index 7d86f75c9d..b47e7f8aab 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -127,6 +127,7 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only } files.sort_unstable(); assert_eq!(files.len(), 1); + let canonical_rollout_summary_file = &files[0]; let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root)) .await @@ -134,6 +135,30 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only assert!(raw_memories.contains("raw memory")); assert!(raw_memories.contains(&keep_id)); assert!(raw_memories.contains("cwd: /tmp/workspace")); + assert!(raw_memories.contains(&format!( + "rollout_summary_file: {canonical_rollout_summary_file}" + ))); + let thread_header = format!("## Thread `{keep_id}`"); + let thread_pos = raw_memories + .find(&thread_header) + .expect("thread header should exist"); + let updated_pos = raw_memories[thread_pos..] + .find("updated_at: ") + .map(|offset| thread_pos + offset) + .expect("updated_at should exist after thread header"); + let cwd_pos = raw_memories[thread_pos..] + .find("cwd: /tmp/workspace") + .map(|offset| thread_pos + offset) + .expect("cwd should exist after thread header"); + let file_pos = raw_memories[thread_pos..] + .find(&format!( + "rollout_summary_file: {canonical_rollout_summary_file}" + )) + .map(|offset| thread_pos + offset) + .expect("rollout_summary_file should exist after thread header"); + assert!(thread_pos < updated_pos); + assert!(updated_pos < cwd_pos); + assert!(cwd_pos < file_pos); } #[tokio::test] @@ -229,7 +254,7 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename( } #[tokio::test] -async fn rebuild_raw_memories_file_rewrites_rollout_summary_file_to_canonical_filename() { +async fn rebuild_raw_memories_file_adds_canonical_rollout_summary_file_header() { let dir = tempdir().expect("tempdir"); let root = dir.path().join("memory"); ensure_layout(&root).await.expect("ensure layout"); @@ -241,11 +266,20 @@ async fn rebuild_raw_memories_file_rewrites_rollout_summary_file_to_canonical_fi source_updated_at: Utc.timestamp_opt(200, 0).single().expect("timestamp"), raw_memory: "\ --- -rollout_summary_file: state_migration_uniqueness_test.md description: Added a migration test keywords: codex-state, migrations --- -- Kept details." +### Task 1: migration-test +task: add-migration-test +task_group: codex-state +task_outcome: success +- Added regression coverage for migration uniqueness. + +### Task 2: validate-migration +task: validate-migration-ordering +task_group: codex-state +task_outcome: success +- Confirmed no ordering regressions." .to_string(), rollout_summary: "short summary".to_string(), rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()), @@ -285,8 +319,11 @@ keywords: codex-state, migrations assert!(raw_memories.contains(&format!( "rollout_summary_file: {canonical_rollout_summary_file}" ))); - assert!(!raw_memories.contains("rollout_summary_file: state_migration_uniqueness_test.md")); assert!(raw_memories.contains("description: Added a migration test")); + assert!(raw_memories.contains("### Task 1: migration-test")); + assert!(raw_memories.contains("task: add-migration-test")); + assert!(raw_memories.contains("task_group: codex-state")); + assert!(raw_memories.contains("task_outcome: success")); } mod phase2 { diff --git a/codex-rs/core/templates/memories/consolidation.md b/codex-rs/core/templates/memories/consolidation.md index de0965082b..91096c1dd3 100644 --- a/codex-rs/core/templates/memories/consolidation.md +++ b/codex-rs/core/templates/memories/consolidation.md @@ -17,7 +17,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE Folder structure (under {{ memory_root }}/): - memory_summary.md - - Always loaded into the system prompt. Must remain tiny and highly navigational. + - Always loaded into the system prompt. Must remain informative and highly navigational, + but still discriminative enough to guide retrieval. - MEMORY.md - Handbook entries. Used to grep for keywords; aggregated insights from rollouts; pointers to rollout summaries if certain past rollouts are very relevant. @@ -40,8 +41,10 @@ GLOBAL SAFETY, HYGIENE, AND NO-FILLER RULES (STRICT) - Evidence-based only: do not invent facts or claim verification that did not happen. - Redact secrets: never store tokens/keys/passwords; replace with [REDACTED_SECRET]. - Avoid copying large tool outputs. Prefer compact summaries + exact error snippets + pointers. -- **No-op is allowed and preferred** when there is no meaningful, reusable learning worth saving. - - If nothing is worth saving, make NO file changes. +- No-op content updates are allowed and preferred when there is no meaningful, reusable + learning worth saving. + - INIT mode: still create minimal required files (`MEMORY.md` and `memory_summary.md`). + - INCREMENTAL UPDATE mode: if nothing is worth saving, make no file changes. ============================================================ WHAT COUNTS AS HIGH-SIGNAL MEMORY @@ -97,7 +100,10 @@ Primary inputs (always read these, if exists): Under `{{ memory_root }}/`: - `raw_memories.md` - mechanical merge of `raw_memories` from Phase 1; - - source of rollout-level metadata needed for MEMORY.md header annotations; + - ordered latest-first; use this recency ordering as a major heuristic when choosing + what to promote, expand, or deprecate; + - source of rollout-level metadata needed for MEMORY.md `### rollout_summary_files` + annotations; you should be able to find `cwd` and `updated_at` there. - `MEMORY.md` - merged memories; produce a lightly clustered version if applicable @@ -123,45 +129,148 @@ Rules: - If there is no meaningful signal to add beyond what already exists, keep outputs minimal. - You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date. - Follow the format and schema of the artifacts below. +- Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the + signal determine the granularity and depth. +- Quality objective: for high-signal task families, `MEMORY.md` should be materially more + useful than `raw_memories.md` while remaining easy to navigate. ============================================================ 1) `MEMORY.md` FORMAT (STRICT) ============================================================ -Clustered schema: ---- -rollout_summary_files: - - () - - () -description: brief description of the shared tasks/outcomes -keywords: k1, k2, k3, ... ---- +`MEMORY.md` is the durable, retrieval-oriented handbook. Each block should be easy to grep +and rich enough to reuse without reopening raw rollout logs. + +Each memory block MUST start with: + +# Task Group: + +scope: + +- `Task Group` is for retrieval. Choose granularity based on memory density: + repo / project / workflow / detail-task family. +- `scope:` is for scanning. Keep it short and operational. + +Body format (strict): + +- Use the task-grouped markdown structure below (headings + bullets). Do not use a flat + bullet dump. +- The header (`# Task Group: ...` + `scope: ...`) is the index. The body contains + task-level detail. +- Every `## Task ` section MUST include task-local rollout files, task-local keywords, + and task-specific learnings. +- Use `-` bullets for lists and learnings. Do not use `*`. +- No bolding text in the memory body. + +Required task-oriented body shape (strict): + +## Task 1: + +task: + +### rollout_summary_files + +- (cwd=, updated_at=, ) + +### keywords + +- + +### learnings + +- +- +- +- cause -> fix> +- +- + +## Task 2: + +task: + +### rollout_summary_files -- - ... -Schema rules (strict): -- Keep entries compact and retrieval-friendly. -- A single note block may correspond to multiple related tasks; aggregate when tasks and lessons align. -- In `rollout_summary_files`, each parenthesized annotation must include - `cwd=` and `updated_at=` copied from that rollout summary metadata. - If missing from an individual rollout summary, recover them from `raw_memories.md`. -- If you need to reference skills, do it in the BODY as bullets, not in the header - (e.g., "- Related skill: skills//SKILL.md"). -- Use lowercase, hyphenated skill folder names. -- Preserve provenance: include the relevant rollout_summary_file(s) for the block. +### keywords -What to write in memory entries: Extract the highest-signal takeaways from the rollout -summaries, especially from "User preferences", "Reusable knowledge", "References", and -"Things that did not work / things that can be improved". -Write what would most help a future agent doing a similar (or adjacent) task: decision -triggers, key steps, proven commands/paths, and failure shields (symptom -> cause -> fix), -plus any stable user preferences. -If a rollout summary contains stable user profile details or preferences that generalize, -capture them here so they're easy to find and can be reflected in memory_summary.md. -The goal of MEMORY.md is to support related-but-not-identical future tasks, so keep -insights slightly more general; when a future task is very similar, expect the agent to -use the rollout summary for full detail. +- ... + +### learnings + +- + +... More `## Task ` sections if needed + +## General Tips + +- [Task 1] +- [Task 1][Task 2] +- + +Schema rules (strict): +- A) Structure and consistency + - Exact block shape: `# Task Group`, `scope:`, one or more `## Task `, and + `## General Tips`. + - Keep all tasks and tips inside the task family implied by the block header. + - Keep entries retrieval-friendly, but not shallow. + - Do not emit placeholder values (`task: task`, `# Task Group: misc`, `scope: general`, etc.). +- B) Task boundaries and clustering + - Primary organization unit is the task (`## Task `), not the rollout file. + - Default mapping: one coherent rollout summary -> one MEMORY block -> one `## Task 1`. + - If a rollout contains multiple distinct tasks, split them into multiple `## Task ` + sections. If those tasks belong to different task families, split into separate + MEMORY blocks (`# Task Group`). + - A MEMORY block may include multiple rollouts only when they belong to the same + task group and the task intent, technical context, and outcome pattern align. + - A single `## Task ` section may cite multiple rollout summaries when they are + iterative attempts or follow-up runs for the same task. + - Do not cluster on keyword overlap alone. + - When in doubt, preserve boundaries (separate tasks/blocks) rather than over-cluster. +- C) Provenance and metadata + - Every `## Task ` section must include `### rollout_summary_files`, `### keywords`, + and `### learnings`. + - `### rollout_summary_files` must be task-local (not a block-wide catch-all list). + - Each rollout annotation must include `cwd=` and `updated_at=`. + If missing from a rollout summary, recover them from `raw_memories.md`. + - Major learnings should be traceable to rollout summaries listed in the same task section. + - Order rollout references by freshness and practical usefulness. +- D) Retrieval and references + - `task:` lines must be specific and searchable. + - `### keywords` should be discriminative and task-local (tool names, error strings, + repo concepts, APIs/contracts). + - Put task-specific detail in `## Task ` and only deduplicated cross-task guidance in + `## General Tips`. + - If you reference skills, do it in body bullets only (for example: + `- Related skill: skills//SKILL.md`). + - Use lowercase, hyphenated skill folder names. +- E) Ordering and conflict handling + - For grouped blocks, order `## Task ` sections by practical usefulness, then recency. + - Treat `updated_at` as a first-class signal: fresher validated evidence usually wins. + - If evidence conflicts and validation is unclear, preserve the uncertainty explicitly. + - In `## General Tips`, cite task references (`[Task 1]`, `[Task 2]`, etc.) when + merging, deduplicating, or resolving evidence. + +What to write: +- Extract the takeaways from rollout summaries and raw_memories, especially sections like + "User preferences", "Reusable knowledge", "References", and "Things that did not work". +- Optimize for future related tasks: decision triggers, validated commands/paths, + verification steps, and failure shields (symptom -> cause -> fix). +- Capture stable user preferences/details that generalize so they can also inform + `memory_summary.md`. +- `MEMORY.md` should support related-but-not-identical tasks: slightly more general than a + rollout summary, but still operational and concrete. +- Use `raw_memories.md` as the routing layer; deep-dive into `rollout_summaries/*.md` when: + - the task is high-value and needs richer detail, + - multiple rollouts overlap and need conflict/staleness resolution, + - raw memory wording is too terse/ambiguous to consolidate confidently, + - you need stronger evidence, validation context, or user feedback. +- Each block should be useful on its own and materially richer than `memory_summary.md`: + - include concrete triggers, commands/paths, and failure shields, + - include outcome-specific notes (what worked, what failed, what remains uncertain), + - include scope boundaries / anti-drift notes when they affect future task success, + - include stale/conflict notes when newer evidence changes prior guidance. ============================================================ 2) `memory_summary.md` FORMAT (STRICT) @@ -210,17 +319,23 @@ For example, include (when known): ## What's in Memory This is a compact index to help future agents quickly find details in `MEMORY.md`, `skills/`, and `rollout_summaries/`. -Organize by topic. Each bullet should include: topic, keywords (used to search over -memory files), and a brief description. +Organize by topic. Each bullet must include: topic, keywords, and a clear description. Ordered by utility - which is the most likely to be useful for a future agent. +Do not target a fixed topic count. Cover the real high-signal areas and omit low-signal noise. +Prefer grouping by task family / workflow intent, not by incidental tools alone. Recommended format: - : , , , ... - - desc: + - desc: Notes: - Do not include large snippets; push details into MEMORY.md and rollout summaries. - Prefer topics/keywords that help a future agent search MEMORY.md efficiently. +- Prefer clear topic taxonomy over verbose drill-down pointers. +- Keep descriptions explicit enough that a future model can decide which keyword cluster + to search first for a new user query. +- Topic descriptions should mention what is inside, when to use it, and what kind of + outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery). ============================================================ 3) `skills/` FORMAT (optional) @@ -303,29 +418,41 @@ WORKFLOW - create initial `skills/*` (optional but highly recommended) - write `memory_summary.md` last (highest-signal file) - Use your best efforts to get the most high-quality memory files - - Do not be lazy at browsing files at the INIT phase + - Do not be lazy at browsing files in INIT mode; deep-dive high-value rollouts and + conflicting task families until MEMORY blocks are richer and more useful than raw memories 3) INCREMENTAL UPDATE behavior: - Treat `raw_memories.md` as the primary source of NEW signal. - Read existing memory files first for continuity. - Integrate new signal into existing artifacts by: + - scanning new raw memories in recency order and identifying which existing blocks they should update - updating existing knowledge with better/newer evidence - updating stale or contradicting guidance + - expanding terse old blocks when new summaries/raw memories make the task family clearer - doing light clustering and merging if needed - updating existing skills or adding new skills only when there is clear new reusable procedure - update `memory_summary.md` last to reflect the final state of the memory folder -4) For both modes, update `MEMORY.md` after skill updates: - - add clear **Related skills** pointers in the BODY of corresponding note blocks (do - not change the YAML header schema) +4) Evidence deep-dive rule (both modes): + - `raw_memories.md` is the routing layer, not always the final authority for detail. + - When a task family is important, ambiguous, or duplicated across multiple rollouts, + open the relevant `rollout_summaries/*.md` files and extract richer procedural detail, + validation signals, and user feedback before finalizing `MEMORY.md`. + - Use `updated_at` and validation strength together to resolve stale/conflicting notes. -5) Housekeeping (optional): +5) For both modes, update `MEMORY.md` after skill updates: + - add clear related-skill pointers as plain bullets in the BODY of corresponding task + sections (do not change the `# Task Group` / `scope:` block header format) + +6) Housekeeping (optional): - remove clearly redundant/low-signal rollout summaries - if multiple summaries overlap for the same thread, keep the best one -6) Final pass: +7) Final pass: - remove duplication in memory_summary, skills/, and MEMORY.md - ensure any referenced skills/summaries actually exist + - ensure MEMORY blocks and "What's in Memory" use a consistent task-oriented taxonomy + - ensure recent important task families are easy to find (description + keywords + topic wording) - if there is no net-new or higher-quality signal to add, keep changes minimal (no churn for its own sake). @@ -341,6 +468,6 @@ Use `rg` for fast retrieval while consolidating: - Search durable notes: `rg -n -i "" "{{ memory_root }}/MEMORY.md"` - Search across memory tree: - `rg -n -i "" "{{ memory_root }}" | head -n 50` + `rg -n -i "" "{{ memory_root }}" | head -n 100` - Locate rollout summary files: - `rg --files "{{ memory_root }}/rollout_summaries" | head -n 200` + `rg --files "{{ memory_root }}/rollout_summaries" | head -n 400` diff --git a/codex-rs/core/templates/memories/read_path.md b/codex-rs/core/templates/memories/read_path.md index 6ef09247b2..ec123fb551 100644 --- a/codex-rs/core/templates/memories/read_path.md +++ b/codex-rs/core/templates/memories/read_path.md @@ -25,8 +25,8 @@ again) - {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets) Quick memory pass (when applicable): -1) Skim the MEMORY_SUMMARY included below and extract a few task-relevant -keywords (for example repo/module names, error strings, etc.). +1) Skim the MEMORY_SUMMARY included below and extract task-relevant topics and +keywords (for example repo/module names, workflows, error strings, etc.). 2) Search {{ base_path }}/MEMORY.md for those keywords, and for any referenced rollout summary files and skills. 3) If relevant rollout summary files and skills exist, open matching files diff --git a/codex-rs/core/templates/memories/stage_one_system.md b/codex-rs/core/templates/memories/stage_one_system.md index 38031e8d2f..692243c900 100644 --- a/codex-rs/core/templates/memories/stage_one_system.md +++ b/codex-rs/core/templates/memories/stage_one_system.md @@ -109,6 +109,16 @@ Typical real-world signals (use as examples when analyzing the rollout): 3) User keeps iterating on the same task: - Requests for fixes/revisions on the same artifact usually mean partial, not success. - Requesting a restart or pointing out contradictions often indicates fail. +4) Last task in the rollout: + - Treat the final task more conservatively than earlier tasks. + - If there is no explicit user feedback or environment validation for the final task, + prefer `uncertain` (or `partial` if there was obvious progress but no confirmation). + - For non-final tasks, switching to another task without unresolved blockers is a stronger + positive signal. + +Signal priority: +- Explicit user feedback and explicit environment/test/tool validation outrank all heuristics. +- If heuristic signals conflict with explicit feedback, follow explicit feedback. Fallback heuristics: - Success: explicit "done/works", tests pass, correct artifact produced, user @@ -152,6 +162,8 @@ This summary should be very comprehensive and detailed, because it will be furth distilled into MEMORY.md and memory_summary.md. There is no strict size limit, and you should feel free to list a lot of points here as long as they are helpful. +Do not target fixed counts (tasks, bullets, references, or topics). Let the rollout's +signal density decide how much to write. Instructional notes in angle brackets are guidance only; do not include them verbatim in the rollout summary. Template (items are flexible; include only what is useful): @@ -170,7 +182,7 @@ User preferences: -## Task : +## Task : Outcome: Key steps: @@ -188,9 +200,9 @@ Things that did not work / things that can be improved: user approval."> - ... -Reusable knowledge: +Reusable knowledge: - - : - [3] final verification evidence or explicit user feedback -## Task (if there are multiple tasks): +## Task (if there are multiple tasks): ... +Task section quality bar (strict): +- Each task section should be detailed enough that other agent can understand it without + reopening the raw rollout. +- For each task, cover the following when evidence exists (and state uncertainty when it + does not): + - what the user wanted / expected, + - what was attempted and what actually worked, + - what failed or remained uncertain and why, + - how the outcome was validated (user feedback, tests, tool output, or explicit lack of validation), + - reusable procedure/checklist and failure shields, + - concrete artifacts/commands/paths/error signatures that future agents can reuse. +- Do not be terse in task sections. Rich, evidence-backed task summaries are preferred + over compact summaries. + ============================================================ `raw_memory` FORMAT (STRICT) ============================================================ The schema is below. --- -rollout_summary_file: -description: brief description of the task and outcome +description: concise but information-dense description of the primary task(s), outcome, and highest-value takeaway +task: +task_group: +task_outcome: keywords: k1, k2, k3, ... --- -- + +Then write task-grouped body content (required): +### Task 1: +task: +task_group: +task_outcome: +- - ... +### Task 2: (if needed) +task: ... +task_group: ... +task_outcome: ... +- ... + +Preferred task-block body shape (strongly recommended): +- `### Task ` blocks should preserve task-specific retrieval signal and consolidation-ready detail. +- Within each task block, include bullets that explicitly cover (when applicable): + - user goal / expected outcome, + - what worked (key steps, commands, code paths, artifacts), + - what did not work or drifted (and what pivot worked), + - validation state (user confirmation, tests, runtime checks, or missing validation), + - reusable procedure/checklist and failure shields, + - high-signal evidence pointers (error strings, commands, files, IDs, URLs, etc.). +- Prefer labeled bullets when useful (for example: `- User goal: ...`, `- Validation: ...`, + `- Failure shield: ...`) so Phase 2 can retrieve and consolidate faster. + +Task grouping rules (strict): +- Every distinct user task in the thread must appear as its own `### Task ` block. +- Do not merge unrelated tasks into one block just because they happen in the same thread. +- If a thread contains only one task, keep exactly one task block. +- For each task block, keep the outcome tied to evidence relevant to that task. +- If a thread has partially related tasks, prefer splitting into separate task blocks and + linking them through shared keywords rather than merging. + What to write in memory entries: Extract useful takeaways from the rollout summaries, especially from "User preferences", "Reusable knowledge", "References", and "Things that did not work / things that can be improved". @@ -249,10 +309,17 @@ Write what would help a future agent doing a similar (or adjacent) task: decisio triggers, key steps, proven commands/paths, and failure shields (symptom -> cause -> fix), plus any stable user preferences. If a rollout summary contains stable user profile details or preferences that generalize, -capture them here so they're easy to find and can be reflected in memory_summary.md. +capture them here so they're easy to find without checking rollout summary. The goal is to support related-but-not-identical future tasks, so keep insights slightly more general; when a future task is very similar, expect the agent to use the rollout summary for full detail. +For each task block, include enough detail to be useful for future agent reference: +- what the user wanted and expected, +- what was attempted and what actually worked, +- what failed or remained uncertain and why, +- what evidence validates the outcome (user feedback, environment/test feedback, or lack of both), +- reusable procedures/checklists and failure shields that should survive future similar tasks, +- artifacts and retrieval handles (commands, file paths, error strings, IDs) that make the task easy to rediscover. ============================================================ @@ -264,4 +331,6 @@ WORKFLOW 1) Triage outcome using the common rules. 2) Read the rollout carefully (do not miss user messages/tool calls/outputs). 3) Return `rollout_summary`, `rollout_slug`, and `raw_memory`, valid JSON only. - No markdown wrapper, no prose outside JSON. \ No newline at end of file + No markdown wrapper, no prose outside JSON. + +- Do not be terse in task sections. Include validation signal, failure mode, and reusable procedure per task when available. From a7632f68a67c0a9bdd1d3e305012a6a708518544 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 09:25:35 +0000 Subject: [PATCH 06/39] Set memories phase reasoning effort constants (#12309) ## Summary - add reasoning effort constants for the memories phase one and phase two agents - wire the constants into phase1 request creation and phase2 agent configuration so the default efforts are always applied ## Testing - Not run (not requested) --- codex-rs/core/src/memories/mod.rs | 6 ++++++ codex-rs/core/src/memories/phase1.rs | 2 +- codex-rs/core/src/memories/phase2.rs | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs index cab19915df..1f892646ce 100644 --- a/codex-rs/core/src/memories/mod.rs +++ b/codex-rs/core/src/memories/mod.rs @@ -13,6 +13,8 @@ mod storage; mod tests; pub(crate) mod usage; +use codex_protocol::openai_models::ReasoningEffort; + /// Starts the memory startup pipeline for eligible root sessions. /// This is the single entrypoint that `codex` uses to trigger memory startup. /// @@ -28,6 +30,8 @@ mod artifacts { mod phase_one { /// Default model used for phase 1. pub(super) const MODEL: &str = "gpt-5.1-codex-mini"; + /// Default reasoning effort used for phase 1. + pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low; /// Prompt used for phase 1. pub(super) const PROMPT: &str = include_str!("../../templates/memories/stage_one_system.md"); /// Concurrency cap for startup memory extraction and consolidation scheduling. @@ -56,6 +60,8 @@ mod phase_one { mod phase_two { /// Default model used for phase 2. pub(super) const MODEL: &str = "gpt-5.3-codex"; + /// Default reasoning effort used for phase 2. + pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Medium; /// Lease duration (seconds) for phase-2 consolidation job ownership. pub(super) const JOB_LEASE_SECONDS: i64 = 3_600; /// Backoff delay (seconds) before retrying a failed phase-2 consolidation diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 36be849c14..b356f61b38 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -142,7 +142,7 @@ impl RequestContext { model_info, turn_metadata_header, otel_manager: turn_context.otel_manager.clone(), - reasoning_effort: turn_context.reasoning_effort, + reasoning_effort: Some(phase_one::REASONING_EFFORT), reasoning_summary: turn_context.reasoning_summary, } } diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index aa17cc1585..ec8c36249d 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -258,6 +258,7 @@ mod agent { .clone() .unwrap_or(phase_two::MODEL.to_string()), ); + agent_config.model_reasoning_effort = Some(phase_two::REASONING_EFFORT); Some(agent_config) } From 03ff04cd658bc34750ab74d2041e616d8aa6bb33 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 11:24:28 +0000 Subject: [PATCH 07/39] chore: nit explorer (#12315) --- codex-rs/core/src/agent/builtins/explorer.toml | 2 -- codex-rs/core/src/agent/role.rs | 6 +++--- codex-rs/core/src/tools/handlers/multi_agents.rs | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/agent/builtins/explorer.toml b/codex-rs/core/src/agent/builtins/explorer.toml index 0b6def0323..e69de29bb2 100644 --- a/codex-rs/core/src/agent/builtins/explorer.toml +++ b/codex-rs/core/src/agent/builtins/explorer.toml @@ -1,2 +0,0 @@ -model = "gpt-5.1-codex-mini" -model_reasoning_effort = "medium" diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 97cde2c8a3..16460fb7fd 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -162,11 +162,10 @@ mod built_in { ( "explorer".to_string(), AgentRoleConfig { - description: Some(r#"Use `explorer` for all codebase questions. + description: Some(r#"Use `explorer` for specific codebase questions. Explorers are fast and authoritative. -Always prefer them over manual search or file reading. +They must be used to ask specific, well-scoped questions on the codebase. Rules: -- Ask explorers first and precisely. - Do not re-read or re-search code they cover. - Trust explorer results without verification. - Run explorers in parallel when useful. @@ -268,6 +267,7 @@ mod tests { } #[tokio::test] + #[ignore = "No role requiring it for now"] async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; let before_layers = session_flags_layer_count(&config); diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 2180574cc5..38aa22b6c4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -990,6 +990,7 @@ mod tests { } #[tokio::test] + #[ignore = "No role requiring it for now"] async fn spawn_agent_uses_explorer_role_and_sets_never_approval_policy() { #[derive(Debug, Deserialize)] struct SpawnAgentResult { From 0f9eed3a6f479395520fcd7aceb9fc5b2f3f3754 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 14:39:49 +0000 Subject: [PATCH 08/39] feat: add nick name to sub-agents (#12320) Adding random nick name to sub-agents. Used for UX At the same time, also storing and wiring the role of the sub-agent --- .../schema/json/ServerNotification.json | 28 ++ .../codex_app_server_protocol.schemas.json | 42 +++ .../v1/GetConversationSummaryResponse.json | 14 + .../json/v1/ListConversationsResponse.json | 14 + .../schema/json/v2/ThreadForkResponse.json | 28 ++ .../schema/json/v2/ThreadListResponse.json | 28 ++ .../schema/json/v2/ThreadReadResponse.json | 28 ++ .../schema/json/v2/ThreadResumeResponse.json | 28 ++ .../json/v2/ThreadRollbackResponse.json | 28 ++ .../schema/json/v2/ThreadStartResponse.json | 28 ++ .../json/v2/ThreadStartedNotification.json | 28 ++ .../json/v2/ThreadUnarchiveResponse.json | 28 ++ .../schema/typescript/SubAgentSource.ts | 2 +- .../schema/typescript/v2/Thread.ts | 8 + .../app-server-protocol/src/protocol/v2.rs | 4 + codex-rs/app-server/README.md | 5 +- .../app-server/src/codex_message_processor.rs | 86 +++++- codex-rs/app-server/src/filters.rs | 2 + codex-rs/app-server/src/thread_status.rs | 2 + codex-rs/app-server/tests/common/rollout.rs | 4 + .../app-server/tests/suite/v2/thread_list.rs | 4 + codex-rs/core/src/agent/agent_names.txt | 100 +++++++ codex-rs/core/src/agent/control.rs | 274 ++++++++++++++++-- codex-rs/core/src/agent/guards.rs | 182 +++++++++++- codex-rs/core/src/personality_migration.rs | 2 + codex-rs/core/src/rollout/list.rs | 14 +- codex-rs/core/src/rollout/metadata.rs | 6 + codex-rs/core/src/rollout/recorder.rs | 7 +- codex-rs/core/src/rollout/tests.rs | 26 ++ .../core/src/tools/handlers/multi_agents.rs | 52 ++-- .../core/tests/suite/personality_migration.rs | 4 + codex-rs/core/tests/suite/sqlite_state.rs | 2 + codex-rs/protocol/src/protocol.rs | 33 +++ .../0013_threads_agent_nickname.sql | 2 + codex-rs/state/src/extract.rs | 4 + codex-rs/state/src/model/thread_metadata.rs | 26 ++ codex-rs/state/src/runtime.rs | 14 +- codex-rs/state/src/runtime/memories.rs | 2 + codex-rs/tui/src/resume_picker.rs | 45 +-- 39 files changed, 1125 insertions(+), 109 deletions(-) create mode 100644 codex-rs/core/src/agent/agent_names.txt create mode 100644 codex-rs/state/migrations/0013_threads_agent_nickname.sql diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f18531303e..128f4fa60d 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6377,6 +6377,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -6524,6 +6538,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "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 39b21d0a54..ea4cd0bd3a 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 @@ -9431,6 +9431,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -15092,6 +15106,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -15218,6 +15246,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json index a54b7ddac1..13cdf0b0f8 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/GetConversationSummaryResponse.json @@ -123,6 +123,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json index bb5a0f4861..2e3ab7e0dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ListConversationsResponse.json @@ -123,6 +123,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 2dfc446ac2..dad39a512a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -716,6 +716,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -777,6 +791,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 2ab535625b..80a1d42a23 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -489,6 +489,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -550,6 +564,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8aa8b0c4fb..a4810d5850 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -489,6 +489,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -550,6 +564,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 2343766326..c4e1df8478 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -716,6 +716,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -777,6 +791,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 4821029283..f8cdcd744c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -489,6 +489,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -550,6 +564,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 1b617a8508..099a65d219 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -716,6 +716,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -777,6 +791,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 6f016b0c77..9927bb2f3c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -489,6 +489,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -550,6 +564,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index a49a3a8f95..011a97a34c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -489,6 +489,20 @@ "properties": { "thread_spawn": { "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, "depth": { "format": "int32", "type": "integer" @@ -550,6 +564,20 @@ }, "Thread": { "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "cliVersion": { "description": "Version of the CLI that created the thread.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts index 269a411be9..df261bf3ea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SubAgentSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadId } from "./ThreadId"; -export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string }; +export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 523a028583..5e7a6b7af4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -43,6 +43,14 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + */ +agentNickname: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agentRole: string | null, /** * Optional Git metadata captured when the thread was created. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6a1959bb25..fb2f86931e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2206,6 +2206,10 @@ pub struct Thread { pub cli_version: String, /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). pub source: SessionSource, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, /// Optional Git metadata captured when the thread was created. pub git_info: Option, /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index fcae5b3069..3d3cc18a9f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -224,6 +224,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). - `cwd` — restrict results to threads whose session cwd exactly matches this path. +- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. Example: @@ -235,7 +236,7 @@ Example: } } { "id": 20, "result": { "data": [ - { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" } }, + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" }, { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } } ], "nextCursor": "opaque-token-or-null" @@ -271,7 +272,7 @@ When `nextCursor` is `null`, you’ve reached the final page. ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ea2701d635..44444494df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6658,6 +6658,8 @@ async fn read_summary_from_state_db_context_by_thread_id( metadata.cwd, metadata.cli_version, metadata.source, + metadata.agent_nickname, + metadata.agent_role, metadata.git_sha, metadata.git_branch, metadata.git_origin_url, @@ -6678,9 +6680,12 @@ async fn summary_from_thread_list_item( .unwrap_or_else(|| fallback_provider.to_string()); let cwd = it.cwd?; let cli_version = it.cli_version.unwrap_or_default(); - let source = it - .source - .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata( + it.source + .unwrap_or(codex_protocol::protocol::SessionSource::Unknown), + it.agent_nickname.clone(), + it.agent_role.clone(), + ); return Some(ConversationSummary { conversation_id: thread_id, path: it.path, @@ -6735,13 +6740,17 @@ fn summary_from_state_db_metadata( cwd: PathBuf, cli_version: String, source: String, + agent_nickname: Option, + agent_role: Option, git_sha: Option, git_branch: Option, git_origin_url: Option, ) -> ConversationSummary { let preview = first_user_message.unwrap_or_default(); - let source = serde_json::from_value(serde_json::Value::String(source)) + let source = serde_json::from_str(&source) + .or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone()))) .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role); let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() { None } else { @@ -6901,6 +6910,35 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { } } +fn with_thread_spawn_agent_metadata( + source: codex_protocol::protocol::SessionSource, + agent_nickname: Option, + agent_role: Option, +) -> codex_protocol::protocol::SessionSource { + if agent_nickname.is_none() && agent_role.is_none() { + return source; + } + + match source { + codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: existing_agent_nickname, + agent_role: existing_agent_role, + }, + ) => codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: agent_nickname.or(existing_agent_nickname), + agent_role: agent_role.or(existing_agent_role), + }, + ), + _ => source, + } +} + fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -6937,6 +6975,8 @@ fn build_thread_from_snapshot( path, cwd: config_snapshot.cwd.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: config_snapshot.session_source.get_nickname(), + agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), git_info: None, turns: Vec::new(), @@ -6975,6 +7015,8 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { path: Some(path), cwd, cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), source: source.into(), git_info, turns: Vec::new(), @@ -6986,6 +7028,7 @@ mod tests { use super::*; use anyhow::Result; use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; @@ -7131,6 +7174,41 @@ mod tests { Ok(()) } + #[test] + fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let source = + serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, + depth: 1, + agent_nickname: None, + agent_role: None, + }))?; + + let summary = summary_from_state_db_metadata( + conversation_id, + PathBuf::from("/tmp/rollout.jsonl"), + Some("hi".to_string()), + "2025-09-05T16:53:11Z".to_string(), + "2025-09-05T16:53:12Z".to_string(), + "test-provider".to_string(), + PathBuf::from("/"), + "0.0.0".to_string(), + source, + Some("atlas".to_string()), + Some("explorer".to_string()), + None, + None, + None, + ); + + let thread = summary_to_thread(summary); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + #[tokio::test] async fn removing_one_listener_does_not_cancel_other_subscriptions_for_same_thread() -> Result<()> { diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs index bd784c3dcc..a597509612 100644 --- a/codex-rs/app-server/src/filters.rs +++ b/codex-rs/app-server/src/filters.rs @@ -133,6 +133,8 @@ mod tests { let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }); assert!(source_kind_matches( diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 7576791d37..301c4b1d07 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -622,6 +622,8 @@ mod tests { path: None, cwd: PathBuf::from("/tmp"), cli_version: "test".to_string(), + agent_nickname: None, + agent_role: None, source, git_info: None, turns: Vec::new(), diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 14dce02c64..8122e7cd01 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -79,6 +79,8 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + agent_nickname: None, + agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, dynamic_tools: None, @@ -158,6 +160,8 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 46e1d170f6..1f6e0627eb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -582,6 +582,8 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }), )?; @@ -643,6 +645,8 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: None, }), )?; let other_id = create_fake_rollout_with_source( diff --git a/codex-rs/core/src/agent/agent_names.txt b/codex-rs/core/src/agent/agent_names.txt new file mode 100644 index 0000000000..6b6846193a --- /dev/null +++ b/codex-rs/core/src/agent/agent_names.txt @@ -0,0 +1,100 @@ +Atlas +Nova +Orion +Iris +Milo +Juno +Mars +Vesta +Luna +Sol +Eros +Nyx +Zeus +Hera +Ares +Ajax +Echo +Leto +Rhea +Gaia +Hades +Apollo +Pallas +Circe +Nereus +Triton +Selene +Helios +Castor +Pollux +Astra +Aura +Thalia +Clio +Erato +Euterpe +Urania +Calliope +Minos +Linus +Cato +Brutus +Seneca +Ovid +Virgil +Horace +Remus +Romulus +Titus +Lucian +Felix +Maximus +Octavia +Claudia +Livia +Aelia +Aurel +Cassia +Sabina +Flavia +Numa +Ceres +Diana +Venus +Pluto +Pan +Ramius +Aether +Chaos +Logos +Ethos +Tethys +Chiron +Talos +Icarus +Daedalus +Hyacinth +Adonis +Perseus +Theseus +Argos +Lemnos +Delos +Sparta +Attica +Corinth +Thebes +Ephyra +Achaea +Cyrene +Sidon +Tyre +Ilium +Etrus +Vercos +Aurex +Novian +Helion +Casson +Aurelix \ No newline at end of file diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 847bb7f761..a9ddde6324 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -3,7 +3,9 @@ use crate::agent::guards::Guards; use crate::agent::status::is_final; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::find_thread_path_by_id_str; use crate::session_prefix::format_subagent_notification_message; +use crate::state_db; use crate::thread_manager::ThreadManagerState; use codex_protocol::ThreadId; use codex_protocol::protocol::Op; @@ -11,11 +13,20 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::watch; +const AGENT_NAMES: &str = include_str!("agent_names.txt"); + +fn agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + /// 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. @@ -48,7 +59,24 @@ impl AgentControl { session_source: Option, ) -> CodexResult { let state = self.upgrade()?; - let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let session_source = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_role, + .. + })) => { + let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?; + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: Some(agent_nickname), + agent_role, + })) + } + other => other, + }; let notification_source = session_source.clone(); // The same `AgentControl` is sent to spawn the thread. @@ -77,12 +105,51 @@ impl AgentControl { pub(crate) async fn resume_agent_from_rollout( &self, config: crate::config::Config, - rollout_path: PathBuf, + thread_id: ThreadId, session_source: SessionSource, ) -> CodexResult { let state = self.upgrade()?; - let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + let session_source = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + .. + }) => { + // Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the + // stored nickname/role from sqlite when available; otherwise leave both unset. + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db::get_state_db(&config, None).await { + 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) + }; + let reserved_agent_nickname = resumed_agent_nickname + .as_deref() + .map(|agent_nickname| { + reservation.reserve_agent_nickname_with_preference( + &agent_nickname_list(), + Some(agent_nickname), + ) + }) + .transpose()?; + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_nickname: reserved_agent_nickname, + agent_role: resumed_agent_role, + }) + } + other => other, + }; let notification_source = session_source.clone(); + let rollout_path = + find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string()) + .await? + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?; let resumed_thread = state .resume_thread_from_rollout_with_source( @@ -234,6 +301,8 @@ mod tests { use crate::agent::agent_status_from_event; use crate::config::Config; use crate::config::ConfigBuilder; + use crate::config_loader::LoaderOverrides; + use crate::features::Feature; use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use codex_protocol::config_types::ModeKind; @@ -261,6 +330,12 @@ mod tests { let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) .cli_overrides(cli_overrides) + .loader_overrides(LoaderOverrides { + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + ..LoaderOverrides::default() + }) .build() .await .expect("load default test config"); @@ -441,11 +516,7 @@ mod tests { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .resume_agent_from_rollout( - config, - PathBuf::from("/tmp/missing-rollout.jsonl"), - SessionSource::Exec, - ) + .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) .await .expect_err("resume_agent should fail without a manager"); assert_eq!( @@ -717,12 +788,6 @@ mod tests { .spawn_agent(config.clone(), text_input("hello"), None) .await .expect("spawn_agent should succeed"); - let rollout_path = manager - .get_thread(resumable_id) - .await - .expect("thread should exist") - .rollout_path() - .expect("rollout path should exist"); let _ = control .shutdown_agent(resumable_id) .await @@ -734,7 +799,7 @@ mod tests { .expect("spawn_agent should succeed for active slot"); let err = control - .resume_agent_from_rollout(config, rollout_path, SessionSource::Exec) + .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) .await .expect_err("resume should respect max threads"); let CodexErr::AgentLimitReached { @@ -766,9 +831,8 @@ mod tests { ); let control = manager.agent_control(); - let missing_rollout = config.codex_home.join("sessions/missing-rollout.jsonl"); let _ = control - .resume_agent_from_rollout(config.clone(), missing_rollout, SessionSource::Exec) + .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) .await .expect_err("resume should fail for missing rollout path"); @@ -795,6 +859,8 @@ mod tests { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), })), ) .await @@ -812,4 +878,176 @@ mod tests { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } + + #[tokio::test] + async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let snapshot = child_thread.config_snapshot().await; + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: seen_parent_thread_id, + depth, + agent_nickname, + agent_role, + }) = snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(seen_parent_thread_id, parent_thread_id); + assert_eq!(depth, 1); + assert!(agent_nickname.is_some()); + assert_eq!(agent_role, Some("explorer".to_string())); + } + + #[tokio::test] + async fn resume_thread_subagent_restores_stored_nickname_and_role() { + let (home, mut config) = test_config().await; + config.features.enable(Feature::Sqlite); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + let mut status_rx = harness + .control + .subscribe_status(child_thread_id) + .await + .expect("status subscription should succeed"); + if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + timeout(Duration::from_secs(5), async { + loop { + status_rx + .changed() + .await + .expect("child status should advance past pending init"); + if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { + break; + } + } + }) + .await + .expect("child should initialize before shutdown"); + } + let original_snapshot = child_thread.config_snapshot().await; + let original_nickname = original_snapshot + .session_source + .get_nickname() + .expect("spawned sub-agent should have a nickname"); + let state_db = child_thread + .state_db() + .expect("sqlite state db should be available for nickname resume test"); + timeout(Duration::from_secs(5), async { + loop { + if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await + && metadata.agent_nickname.is_some() + && metadata.agent_role.as_deref() == Some("explorer") + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child thread metadata should be persisted to sqlite before shutdown"); + + let _ = harness + .control + .shutdown_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + + let resumed_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("resume should succeed"); + assert_eq!(resumed_thread_id, child_thread_id); + + let resumed_snapshot = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should exist") + .config_snapshot() + .await; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: resumed_parent_thread_id, + depth: resumed_depth, + agent_nickname: resumed_nickname, + agent_role: resumed_role, + }) = resumed_snapshot.session_source + else { + panic!("expected thread-spawn sub-agent source"); + }; + assert_eq!(resumed_parent_thread_id, parent_thread_id); + assert_eq!(resumed_depth, 1); + assert_eq!(resumed_nickname, Some(original_nickname)); + assert_eq!(resumed_role, Some("explorer".to_string())); + + let _ = harness + .control + .shutdown_agent(resumed_thread_id) + .await + .expect("resumed child shutdown should submit"); + } } diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 1964169cd7..223373c6df 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -3,6 +3,8 @@ use crate::error::Result; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use rand::prelude::IndexedRandom; +use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; @@ -17,10 +19,18 @@ use std::sync::atomic::Ordering; /// is). #[derive(Default)] pub(crate) struct Guards { - threads_set: Mutex>, + active_agents: Mutex, total_count: AtomicUsize, } +#[derive(Default)] +struct ActiveAgents { + threads_set: HashSet, + thread_agent_nicknames: HashMap, + used_agent_nicknames: HashSet, + nickname_reset_count: usize, +} + fn session_depth(session_source: &SessionSource) -> i32 { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, @@ -52,28 +62,69 @@ impl Guards { Ok(SpawnReservation { state: Arc::clone(self), active: true, + reserved_agent_nickname: None, }) } pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { let removed = { - let mut threads = self - .threads_set + let mut active_agents = self + .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - threads.remove(&thread_id) + let removed = active_agents.threads_set.remove(&thread_id); + active_agents.thread_agent_nicknames.remove(&thread_id); + removed }; if removed { self.total_count.fetch_sub(1, Ordering::AcqRel); } } - fn register_spawned_thread(&self, thread_id: ThreadId) { - let mut threads = self - .threads_set + fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option) { + let mut active_agents = self + .active_agents .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - threads.insert(thread_id); + active_agents.threads_set.insert(thread_id); + if let Some(agent_nickname) = agent_nickname { + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + active_agents + .thread_agent_nicknames + .insert(thread_id, agent_nickname); + } + } + + fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let agent_nickname = if let Some(preferred) = preferred { + preferred.to_string() + } else { + if names.is_empty() { + return None; + } + let available_names: Vec<&str> = names + .iter() + .copied() + .filter(|name| !active_agents.used_agent_nicknames.contains(*name)) + .collect(); + if let Some(name) = available_names.choose(&mut rand::rng()) { + (*name).to_string() + } else { + active_agents.used_agent_nicknames.clear(); + active_agents.nickname_reset_count += 1; + names.choose(&mut rand::rng())?.to_string() + } + }; + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + Some(agent_nickname) } fn try_increment_spawned(&self, max_threads: usize) -> bool { @@ -98,11 +149,41 @@ impl Guards { pub(crate) struct SpawnReservation { state: Arc, active: bool, + reserved_agent_nickname: Option, } impl SpawnReservation { - pub(crate) fn commit(mut self, thread_id: ThreadId) { - self.state.register_spawned_thread(thread_id); + pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result { + self.reserve_agent_nickname_with_preference(names, None) + } + + pub(crate) fn reserve_agent_nickname_with_preference( + &mut self, + names: &[&str], + preferred: Option<&str>, + ) -> Result { + let agent_nickname = self + .state + .reserve_agent_nickname(names, preferred) + .ok_or_else(|| { + CodexErr::UnsupportedOperation("no available agent nicknames".to_string()) + })?; + self.reserved_agent_nickname = Some(agent_nickname.clone()); + Ok(agent_nickname) + } + + pub(crate) fn commit(self, thread_id: ThreadId) { + self.commit_with_agent_nickname(thread_id, None); + } + + pub(crate) fn commit_with_agent_nickname( + mut self, + thread_id: ThreadId, + agent_nickname: Option, + ) { + let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname); + self.state + .register_spawned_thread(thread_id, agent_nickname); self.active = false; } } @@ -130,6 +211,8 @@ mod tests { let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: ThreadId::new(), depth: 1, + agent_nickname: None, + agent_role: None, }); let child_depth = next_thread_spawn_depth(&session_source); assert_eq!(child_depth, 2); @@ -232,4 +315,83 @@ mod tests { .expect("slot released after second thread removal"); drop(reservation); } + + #[test] + fn failed_spawn_keeps_nickname_marked_used() { + let guards = Arc::new(Guards::default()); + let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname(&["alpha"]) + .expect("reserve agent name"); + assert_eq!(agent_nickname, "alpha"); + drop(reservation); + + let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("unused name should still be preferred"); + assert_eq!(agent_nickname, "beta"); + } + + #[test] + fn agent_nickname_resets_used_pool_when_exhausted() { + let guards = Arc::new(Guards::default()); + let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname(&["alpha"]) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(first_id); + assert_eq!(first_name, "alpha"); + + let mut second = guards + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname(&["alpha"]) + .expect("name should be reused after pool reset"); + assert_eq!(second_name, "alpha"); + let active_agents = guards + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); + } + + #[test] + fn released_nickname_stays_used_until_pool_reset() { + let guards = Arc::new(Guards::default()); + + let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname(&["alpha"]) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(first_id); + assert_eq!(first_name, "alpha"); + + guards.release_spawned_thread(first_id); + + let mut second = guards + .reserve_spawn_slot(None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("released name should still be marked used"); + assert_eq!(second_name, "beta"); + let second_id = ThreadId::new(); + second.commit(second_id); + guards.release_spawned_thread(second_id); + + let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname(&["alpha", "beta"]) + .expect("pool reset should permit a duplicate"); + assert!(third_name == "alpha" || third_name == "beta"); + let active_agents = guards + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); + } } diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index 4ec78696b6..c207295e96 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -172,6 +172,8 @@ mod tests { originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 9b2b00c78b..8c9ee7afec 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -39,7 +39,7 @@ pub struct ThreadsPage { } /// Summary information for a thread rollout file. -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Default)] pub struct ThreadItem { /// Absolute path to the rollout file. pub path: PathBuf, @@ -57,6 +57,10 @@ pub struct ThreadItem { pub git_origin_url: Option, /// Session source from session metadata. pub source: Option, + /// Random unique nickname from session metadata for AgentControl-spawned sub-agents. + pub agent_nickname: Option, + /// Role (agent_role) from session metadata for AgentControl-spawned sub-agents. + pub agent_role: Option, /// Model provider from session metadata. pub model_provider: Option, /// CLI version from session metadata. @@ -87,6 +91,8 @@ struct HeadTailSummary { git_sha: Option, git_origin_url: Option, source: Option, + agent_nickname: Option, + agent_role: Option, model_provider: Option, cli_version: Option, created_at: Option, @@ -715,6 +721,8 @@ async fn build_thread_item( git_sha, git_origin_url, source, + agent_nickname, + agent_role, model_provider, cli_version, created_at, @@ -733,6 +741,8 @@ async fn build_thread_item( git_sha, git_origin_url, source, + agent_nickname, + agent_role, model_provider, cli_version, created_at, @@ -1017,6 +1027,8 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { if !summary.saw_session_meta { summary.source = Some(session_meta_line.meta.source.clone()); + summary.agent_nickname = session_meta_line.meta.agent_nickname.clone(); + summary.agent_role = session_meta_line.meta.agent_role.clone(); summary.model_provider = session_meta_line.meta.model_provider.clone(); summary.thread_id = Some(session_meta_line.meta.id); summary.cwd = Some(session_meta_line.meta.cwd.clone()); diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/core/src/rollout/metadata.rs index 7298877751..9196931daf 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/core/src/rollout/metadata.rs @@ -47,6 +47,8 @@ pub(crate) fn builder_from_session_meta( session_meta.meta.source.clone(), ); builder.model_provider = session_meta.meta.model_provider.clone(); + builder.agent_nickname = session_meta.meta.agent_nickname.clone(); + builder.agent_role = session_meta.meta.agent_role.clone(); builder.cwd = session_meta.meta.cwd.clone(); builder.cli_version = Some(session_meta.meta.cli_version.clone()); builder.sandbox_policy = SandboxPolicy::new_read_only_policy(); @@ -512,6 +514,8 @@ mod tests { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: Some("openai".to_string()), base_instructions: None, dynamic_tools: None, @@ -660,6 +664,8 @@ mod tests { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: Some("test-provider".to_string()), base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index eab442743e..7e639e382e 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -391,6 +391,8 @@ impl RolloutRecorder { cwd: config.cwd.clone(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), source, model_provider: Some(config.model_provider_id.clone()), base_instructions: Some(base_instructions), @@ -929,9 +931,12 @@ impl From for ThreadsPage { git_sha: item.git_sha, git_origin_url: item.git_origin_url, source: Some( - serde_json::from_value(Value::String(item.source)) + serde_json::from_str(item.source.as_str()) + .or_else(|_| serde_json::from_value(Value::String(item.source))) .unwrap_or(SessionSource::Unknown), ), + agent_nickname: item.agent_nickname, + agent_role: item.agent_role, model_provider: Some(item.model_provider), cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index b97edb0f66..0eabaee745 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -560,6 +560,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), @@ -574,6 +576,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), @@ -588,6 +592,8 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), @@ -695,6 +701,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), @@ -709,6 +717,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), @@ -759,6 +769,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), @@ -773,6 +785,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), @@ -814,6 +828,8 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), @@ -894,6 +910,8 @@ async fn test_get_thread_contents() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), @@ -1086,6 +1104,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + agent_nickname: None, + agent_role: None, model_provider: Some("test-provider".into()), base_instructions: None, dynamic_tools: None, @@ -1203,6 +1223,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), @@ -1217,6 +1239,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), @@ -1258,6 +1282,8 @@ async fn test_stable_ordering_same_second_pagination() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + agent_nickname: None, + agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 38aa22b6c4..dfc82ce52c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -156,7 +156,11 @@ mod spawn { .spawn_agent( config, input_items, - Some(thread_spawn_source(session.conversation_id, child_depth)), + Some(thread_spawn_source( + session.conversation_id, + child_depth, + role_name, + )), ) .await .map_err(collab_spawn_error); @@ -284,7 +288,6 @@ mod send_input { mod resume_agent { use super::*; use crate::agent::next_thread_spawn_depth; - use crate::rollout::find_thread_path_by_id_str; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -331,15 +334,7 @@ mod resume_agent { .await; let error = if matches!(status, AgentStatus::NotFound) { // If the thread is no longer active, attempt to restore it from rollout. - match try_resume_closed_agent( - &session, - &turn, - receiver_thread_id, - &args.id, - child_depth, - ) - .await - { + match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await { Ok(resumed_status) => { status = resumed_status; None @@ -388,33 +383,16 @@ mod resume_agent { session: &Arc, turn: &Arc, receiver_thread_id: ThreadId, - receiver_id: &str, child_depth: i32, ) -> Result { - let rollout_path = find_thread_path_by_id_str( - turn.config.codex_home.as_path(), - receiver_id, - ) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "tool failed: failed to locate rollout for agent {receiver_thread_id}: {err}" - )) - })? - .ok_or_else(|| { - FunctionCallError::RespondToModel(format!( - "agent with id {receiver_thread_id} not found" - )) - })?; - let config = build_agent_resume_config(turn.as_ref(), child_depth)?; let resumed_thread_id = session .services .agent_control .resume_agent_from_rollout( config, - rollout_path, - thread_spawn_source(session.conversation_id, child_depth), + receiver_thread_id, + thread_spawn_source(session.conversation_id, child_depth, None), ) .await .map_err(|err| collab_agent_error(receiver_thread_id, err))?; @@ -733,10 +711,16 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { } } -fn thread_spawn_source(parent_thread_id: ThreadId, depth: i32) -> SessionSource { +fn thread_spawn_source( + parent_thread_id: ThreadId, + depth: i32, + agent_role: Option<&str>, +) -> SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), }) } @@ -1068,6 +1052,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( @@ -1104,6 +1090,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( @@ -1487,6 +1475,8 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, depth: DEFAULT_AGENT_MAX_DEPTH, + agent_nickname: None, + agent_role: None, }); let invocation = invocation( diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 2ca7aeeefd..dfff16ea03 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -66,6 +66,8 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, @@ -107,6 +109,8 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index b37683a430..8f0e5834e9 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -123,6 +123,8 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: Some(dynamic_tools_for_hook), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 476f902085..a759f15129 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1861,6 +1861,10 @@ pub enum SubAgentSource { ThreadSpawn { parent_thread_id: ThreadId, depth: i32, + #[serde(default)] + agent_nickname: Option, + #[serde(default, alias = "agent_type")] + agent_role: Option, }, MemoryConsolidation, Other(String), @@ -1879,6 +1883,26 @@ impl fmt::Display for SessionSource { } } +impl SessionSource { + pub fn get_nickname(&self) -> Option { + match self { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { + agent_nickname.clone() + } + _ => None, + } + } + + pub fn get_agent_role(&self) -> Option { + match self { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { + agent_role.clone() + } + _ => None, + } + } +} + impl fmt::Display for SubAgentSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -1888,6 +1912,7 @@ impl fmt::Display for SubAgentSource { SubAgentSource::ThreadSpawn { parent_thread_id, depth, + .. } => { write!(f, "thread_spawn_{parent_thread_id}_d{depth}") } @@ -1912,6 +1937,12 @@ pub struct SessionMeta { pub cli_version: String, #[serde(default)] pub source: SessionSource, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, pub model_provider: Option, /// base_instructions for the session. This *should* always be present when creating a new session, /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions @@ -1931,6 +1962,8 @@ impl Default for SessionMeta { originator: String::new(), cli_version: String::new(), source: SessionSource::default(), + agent_nickname: None, + agent_role: None, model_provider: None, base_instructions: None, dynamic_tools: None, diff --git a/codex-rs/state/migrations/0013_threads_agent_nickname.sql b/codex-rs/state/migrations/0013_threads_agent_nickname.sql new file mode 100644 index 0000000000..80394c88b5 --- /dev/null +++ b/codex-rs/state/migrations/0013_threads_agent_nickname.sql @@ -0,0 +1,2 @@ +ALTER TABLE threads ADD COLUMN agent_nickname TEXT; +ALTER TABLE threads ADD COLUMN agent_role TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index f8f9cb5251..0ba3df6790 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -37,6 +37,8 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); + metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); + metadata.agent_role = meta_line.meta.agent_role.clone(); if let Some(provider) = meta_line.meta.model_provider.as_deref() { metadata.model_provider = provider.to_string(); } @@ -216,6 +218,8 @@ mod tests { created_at, updated_at: created_at, source: "cli".to_string(), + agent_nickname: None, + agent_role: None, model_provider: "openai".to_string(), cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index d29bf07606..9533b8fede 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -62,6 +62,10 @@ pub struct ThreadMetadata { pub updated_at: DateTime, /// The session source (stringified enum). pub source: String, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, /// The model provider identifier. pub model_provider: String, /// The working directory for the thread. @@ -101,6 +105,10 @@ pub struct ThreadMetadataBuilder { pub updated_at: Option>, /// The session source. pub source: SessionSource, + /// Optional random unique nickname assigned to the session. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to the session. + pub agent_role: Option, /// The model provider identifier, if known. pub model_provider: Option, /// The working directory for the thread. @@ -135,6 +143,8 @@ impl ThreadMetadataBuilder { created_at, updated_at: None, source, + agent_nickname: None, + agent_role: None, model_provider: None, cwd: PathBuf::new(), cli_version: None, @@ -163,6 +173,8 @@ impl ThreadMetadataBuilder { created_at, updated_at, source, + agent_nickname: self.agent_nickname.clone(), + agent_role: self.agent_role.clone(), model_provider: self .model_provider .clone() @@ -201,6 +213,12 @@ impl ThreadMetadata { if self.source != other.source { diffs.push("source"); } + if self.agent_nickname != other.agent_nickname { + diffs.push("agent_nickname"); + } + if self.agent_role != other.agent_role { + diffs.push("agent_role"); + } if self.model_provider != other.model_provider { diffs.push("model_provider"); } @@ -252,6 +270,8 @@ pub(crate) struct ThreadRow { created_at: i64, updated_at: i64, source: String, + agent_nickname: Option, + agent_role: Option, model_provider: String, cwd: String, cli_version: String, @@ -274,6 +294,8 @@ impl ThreadRow { created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, source: row.try_get("source")?, + agent_nickname: row.try_get("agent_nickname")?, + agent_role: row.try_get("agent_role")?, model_provider: row.try_get("model_provider")?, cwd: row.try_get("cwd")?, cli_version: row.try_get("cli_version")?, @@ -300,6 +322,8 @@ impl TryFrom for ThreadMetadata { created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -319,6 +343,8 @@ impl TryFrom for ThreadMetadata { created_at: epoch_seconds_to_datetime(created_at)?, updated_at: epoch_seconds_to_datetime(updated_at)?, source, + agent_nickname, + agent_role, model_provider, cwd: PathBuf::from(cwd), cli_version, diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 4570532e69..3635bcb865 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -211,6 +211,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -310,6 +312,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -691,6 +695,8 @@ INSERT INTO threads ( created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, @@ -704,12 +710,14 @@ INSERT INTO threads ( git_sha, git_branch, git_origin_url -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, updated_at = excluded.updated_at, source = excluded.source, + agent_nickname = excluded.agent_nickname, + agent_role = excluded.agent_role, model_provider = excluded.model_provider, cwd = excluded.cwd, cli_version = excluded.cli_version, @@ -730,6 +738,8 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_seconds(metadata.created_at)) .bind(datetime_to_epoch_seconds(metadata.updated_at)) .bind(metadata.source.as_str()) + .bind(metadata.agent_nickname.as_deref()) + .bind(metadata.agent_role.as_deref()) .bind(metadata.model_provider.as_str()) .bind(metadata.cwd.display().to_string()) .bind(metadata.cli_version.as_str()) @@ -3092,6 +3102,8 @@ VALUES (?, ?, ?, ?, ?) created_at: now, updated_at: now, source: "cli".to_string(), + agent_nickname: None, + agent_role: None, model_provider: "test-provider".to_string(), cwd, cli_version: "0.0.0".to_string(), diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index c2a7f1fe91..f2a50e3c99 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -95,6 +95,8 @@ SELECT created_at, updated_at, source, + agent_nickname, + agent_role, model_provider, cwd, cli_version, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c9aa9aeced..6cb1387a4e 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1327,17 +1327,10 @@ mod tests { fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem { ThreadItem { path: PathBuf::from(path), - thread_id: None, first_user_message: Some(preview.to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some(ts.to_string()), updated_at: Some(ts.to_string()), + ..Default::default() } } @@ -1462,17 +1455,10 @@ mod tests { fn head_to_row_uses_first_user_message() { let item = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("real question".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), + ..Default::default() }; let row = head_to_row(&item); assert_eq!(row.preview, "real question"); @@ -1483,31 +1469,17 @@ mod tests { // Construct two items with different timestamps and real user text. let a = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("A".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T00:00:00Z".into()), + ..Default::default() }; let b = ThreadItem { path: PathBuf::from("/tmp/b.jsonl"), - thread_id: None, first_user_message: Some("B".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-02T00:00:00Z".into()), updated_at: Some("2025-01-02T00:00:00Z".into()), + ..Default::default() }; let rows = rows_from_items(vec![a, b]); assert_eq!(rows.len(), 2); @@ -1520,17 +1492,10 @@ mod tests { fn row_uses_tail_timestamp_for_updated_at() { let item = ThreadItem { path: PathBuf::from("/tmp/a.jsonl"), - thread_id: None, first_user_message: Some("Hello".to_string()), - cwd: None, - git_branch: None, - git_sha: None, - git_origin_url: None, - source: None, - model_provider: None, - cli_version: None, created_at: Some("2025-01-01T00:00:00Z".into()), updated_at: Some("2025-01-01T01:00:00Z".into()), + ..Default::default() }; let row = head_to_row(&item); From 595665de35a4c22fcda3c479063b709f0de4d7a2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 14:51:09 +0000 Subject: [PATCH 09/39] chore: better agent names (#12328) --- codex-rs/core/src/agent/agent_names.txt | 185 +++++++++++------------- 1 file changed, 86 insertions(+), 99 deletions(-) diff --git a/codex-rs/core/src/agent/agent_names.txt b/codex-rs/core/src/agent/agent_names.txt index 6b6846193a..e5a712ff5c 100644 --- a/codex-rs/core/src/agent/agent_names.txt +++ b/codex-rs/core/src/agent/agent_names.txt @@ -1,100 +1,87 @@ -Atlas -Nova -Orion +Ash +Elm +Yew +Fir +Oak +Pine +Spruce +Cedar +Birch +Maple +Beech +Alder +Willow +Poplar +Aspen +Larch +Juniper +Cypress +Hemlock +Sequoia +Baobab +Acacia +Banyan +Teak +Olive +Cherry +Apple +Pear +Plum +Peach +Mango +Guava +Fig +Date +Palm +Holly +Ivy +Fern +Moss +Reed +Cane +Rush +Sage +Mint +Thyme +Basil +Dill +Fennel +Parsley +Laurel +Myrtle +Lotus +Tulip +Rose +Daisy +Lily Iris -Milo -Juno -Mars -Vesta -Luna -Sol -Eros -Nyx -Zeus -Hera -Ares -Ajax -Echo -Leto -Rhea -Gaia -Hades -Apollo -Pallas -Circe -Nereus -Triton -Selene -Helios -Castor -Pollux -Astra -Aura -Thalia -Clio -Erato -Euterpe -Urania -Calliope -Minos -Linus -Cato -Brutus -Seneca -Ovid -Virgil -Horace -Remus -Romulus -Titus -Lucian -Felix -Maximus -Octavia -Claudia -Livia -Aelia -Aurel -Cassia -Sabina -Flavia -Numa -Ceres -Diana -Venus -Pluto -Pan -Ramius -Aether -Chaos -Logos -Ethos -Tethys -Chiron -Talos -Icarus -Daedalus -Hyacinth -Adonis -Perseus -Theseus -Argos -Lemnos -Delos -Sparta -Attica -Corinth -Thebes -Ephyra -Achaea -Cyrene -Sidon -Tyre -Ilium -Etrus -Vercos -Aurex -Novian -Helion -Casson -Aurelix \ No newline at end of file +Poppy +Aster +Peony +Camellia +Azalea +Begonia +Petunia +Verbena +Marigold +Daffodil +Orchid +Violet +Clover +Bamboo +Eucalypt +Oleander +Yucca +Agave +Aloe +Cactus +Sedum +Boxwood +Hawthorn +Redwood +Ironwood +Sandal +Kapok +Mahonia +Spirea +Wisteria \ No newline at end of file From ede561b5d10a39463c68469c15a5d7c3034a988f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 14:51:17 +0000 Subject: [PATCH 10/39] disable collab for phase 2 (#12326) --- codex-rs/core/src/memories/phase2.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index ec8c36249d..6794a4574f 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -2,6 +2,7 @@ use crate::agent::AgentStatus; use crate::agent::status::is_final as is_final_agent_status; use crate::codex::Session; use crate::config::Config; +use crate::features::Feature; use crate::memories::memory_root; use crate::memories::metrics; use crate::memories::phase_two; @@ -227,6 +228,8 @@ mod agent { agent_config.cwd = root; // Approval policy agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); + // Consolidation runs as an internal sub-agent and must not recursively delegate. + agent_config.features.disable(Feature::Collab); // Sandbox policy let mut writable_roots = Vec::new(); From 2036a5f5e07c99eb1930f9e8ec207e8bf7e94afe Mon Sep 17 00:00:00 2001 From: colby-oai <228809017+colby-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:26:19 -0500 Subject: [PATCH 11/39] Add MCP server context to otel tool_result logs (#12267) Summary - capture the origin for each configured MCP server and expose it via the connection manager - plumb MCP server name/origin into tool logging and emit codex.tool_result events with those fields - add unit coverage for origin parsing and extend OTEL tests to assert empty MCP fields for non-MCP tools - currently not logging full urls or url paths to prevent logging potentially sensitive data Testing - Not run (not requested) --- codex-rs/core/src/mcp_connection_manager.rs | 50 ++++++++++++++++ codex-rs/core/src/tools/registry.rs | 21 +++++++ codex-rs/core/tests/suite/otel.rs | 62 ++++++++++++++++++++ codex-rs/otel/src/traces/otel_manager.rs | 13 ++++ codex-rs/otel/tests/suite/runtime_summary.rs | 2 + 5 files changed, 148 insertions(+) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index c76e19809f..05ac2a9a9b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -73,6 +73,7 @@ use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::instrument; use tracing::warn; +use url::Url; use crate::codex::INITIAL_SUBMIT_ID; use crate::config::types::McpServerConfig; @@ -507,6 +508,7 @@ pub struct SandboxState { /// A thin wrapper around a set of running [`RmcpClient`] instances. pub(crate) struct McpConnectionManager { clients: HashMap, + server_origins: HashMap, elicitation_requests: ElicitationRequestManager, } @@ -514,6 +516,7 @@ impl McpConnectionManager { pub(crate) fn new_uninitialized(approval_policy: &Constrained) -> Self { Self { clients: HashMap::new(), + server_origins: HashMap::new(), elicitation_requests: ElicitationRequestManager::new(approval_policy.value()), } } @@ -529,6 +532,10 @@ impl McpConnectionManager { !self.clients.is_empty() } + pub(crate) fn server_origin(&self, server_name: &str) -> Option<&str> { + self.server_origins.get(server_name).map(String::as_str) + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -548,10 +555,14 @@ impl McpConnectionManager { ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); + let mut server_origins = HashMap::new(); let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::new(approval_policy.value()); let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { + if let Some(origin) = transport_origin(&cfg.transport) { + server_origins.insert(server_name.clone(), origin); + } let cancel_token = cancel_token.child_token(); let _ = emit_update( &tx_event, @@ -624,6 +635,7 @@ impl McpConnectionManager { } let manager = Self { clients, + server_origins, elicitation_requests: elicitation_requests.clone(), }; tokio::spawn(async move { @@ -1448,6 +1460,16 @@ fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { } } +fn transport_origin(transport: &McpServerTransportConfig) -> Option { + match transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + let parsed = Url::parse(url).ok()?; + Some(parsed.origin().ascii_serialization()) + } + McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()), + } +} + async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, @@ -2164,4 +2186,32 @@ mod tests { display ); } + + #[test] + fn transport_origin_extracts_http_origin() { + let transport = McpServerTransportConfig::StreamableHttp { + url: "https://example.com:8443/path?query=1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }; + + assert_eq!( + transport_origin(&transport), + Some("https://example.com:8443".to_string()) + ); + } + + #[test] + fn transport_origin_is_stdio_for_stdio_transport() { + let transport = McpServerTransportConfig::Stdio { + command: "server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }; + + assert_eq!(transport_origin(&transport), Some("stdio".to_string())); + } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 4ffb7ca107..7a0be68df3 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -102,6 +102,21 @@ impl ToolRegistry { sandbox_policy_tag(&invocation.turn.sandbox_policy), ), ]; + let (mcp_server, mcp_server_origin) = match &invocation.payload { + ToolPayload::Mcp { server, .. } => { + let manager = invocation + .session + .services + .mcp_connection_manager + .read() + .await; + let origin = manager.server_origin(server).map(str::to_owned); + (Some(server.clone()), origin) + } + _ => (None, None), + }; + let mcp_server_ref = mcp_server.as_deref(); + let mcp_server_origin_ref = mcp_server_origin.as_deref(); let handler = match self.handler(tool_name.as_ref()) { Some(handler) => handler, @@ -116,6 +131,8 @@ impl ToolRegistry { false, &message, &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, ); return Err(FunctionCallError::RespondToModel(message)); } @@ -131,6 +148,8 @@ impl ToolRegistry { false, &message, &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, ); return Err(FunctionCallError::Fatal(message)); } @@ -146,6 +165,8 @@ impl ToolRegistry { &call_id_owned, log_payload.as_ref(), &metric_tags, + mcp_server_ref, + mcp_server_origin_ref, || { let handler = handler.clone(); let output_cell = &output_cell; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index fd3d1ca16c..03f87aec14 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -32,6 +32,64 @@ use tracing_test::traced_test; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; +fn extract_log_field(line: &str, key: &str) -> Option { + let quoted_prefix = format!("{key}=\""); + if let Some(start) = line.find("ed_prefix) { + let value_start = start + quoted_prefix.len(); + if let Some(end_rel) = line[value_start..].find('"') { + return Some(line[value_start..value_start + end_rel].to_string()); + } + } + + let bare_prefix = format!("{key}="); + for token in line.split_whitespace() { + let trimmed = token.trim_end_matches(','); + if let Some(value) = trimmed.strip_prefix(&bare_prefix) { + return Some(value.to_string()); + } + } + + None +} + +fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> { + let mcp_server = extract_log_field(line, "mcp_server") + .ok_or_else(|| "missing mcp_server field".to_string())?; + if !mcp_server.is_empty() { + return Err(format!("expected empty mcp_server, got {mcp_server}")); + } + + let mcp_server_origin = extract_log_field(line, "mcp_server_origin") + .ok_or_else(|| "missing mcp_server_origin field".to_string())?; + if !mcp_server_origin.is_empty() { + return Err(format!( + "expected empty mcp_server_origin, got {mcp_server_origin}" + )); + } + + Ok(()) +} + +#[test] +fn extract_log_field_handles_empty_bare_values() { + let line = "event.name=\"codex.tool_result\" mcp_server= mcp_server_origin="; + assert_eq!(extract_log_field(line, "mcp_server"), Some(String::new())); + assert_eq!( + extract_log_field(line, "mcp_server_origin"), + Some(String::new()) + ); +} + +#[test] +fn extract_log_field_does_not_confuse_similar_keys() { + let line = "event.name=\"codex.tool_result\" mcp_server_origin=stdio"; + assert_eq!(extract_log_field(line, "mcp_server"), None); + assert_eq!( + extract_log_field(line, "mcp_server_origin"), + Some("stdio".to_string()) + ); +} + #[tokio::test] #[traced_test] async fn responses_api_emits_api_request_event() { @@ -687,6 +745,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -756,6 +815,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -828,6 +888,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); @@ -899,6 +960,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { if !line.contains("success=false") { return Err("missing success field".to_string()); } + assert_empty_mcp_tool_fields(line)?; Ok(()) }); diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs index e3ba85eab1..9331801f9d 100644 --- a/codex-rs/otel/src/traces/otel_manager.rs +++ b/codex-rs/otel/src/traces/otel_manager.rs @@ -588,12 +588,15 @@ impl OtelManager { ); } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, tool_name: &str, call_id: &str, arguments: &str, extra_tags: &[(&str, &str)], + mcp_server: Option<&str>, + mcp_server_origin: Option<&str>, f: F, ) -> Result<(String, bool), E> where @@ -618,6 +621,8 @@ impl OtelManager { success, output.as_ref(), extra_tags, + mcp_server, + mcp_server_origin, ); result @@ -641,6 +646,8 @@ impl OtelManager { duration_ms = %Duration::ZERO.as_millis(), success = %false, output = %error, + mcp_server = "", + mcp_server_origin = "", ); } @@ -654,6 +661,8 @@ impl OtelManager { success: bool, output: &str, extra_tags: &[(&str, &str)], + mcp_server: Option<&str>, + mcp_server_origin: Option<&str>, ) { let success_str = if success { "true" } else { "false" }; let mut tags = Vec::with_capacity(2 + extra_tags.len()); @@ -662,6 +671,8 @@ impl OtelManager { tags.extend_from_slice(extra_tags); self.counter(TOOL_CALL_COUNT_METRIC, 1, &tags); self.record_duration(TOOL_CALL_DURATION_METRIC, duration, &tags); + let mcp_server = mcp_server.unwrap_or(""); + let mcp_server_origin = mcp_server_origin.unwrap_or(""); tracing::event!( tracing::Level::INFO, event.name = "codex.tool_result", @@ -681,6 +692,8 @@ impl OtelManager { duration_ms = %duration.as_millis(), success = %success_str, output = %output, + mcp_server = %mcp_server, + mcp_server_origin = %mcp_server_origin, ); } diff --git a/codex-rs/otel/tests/suite/runtime_summary.rs b/codex-rs/otel/tests/suite/runtime_summary.rs index 0737e0797f..4020e70b45 100644 --- a/codex-rs/otel/tests/suite/runtime_summary.rs +++ b/codex-rs/otel/tests/suite/runtime_summary.rs @@ -44,6 +44,8 @@ fn runtime_metrics_summary_collects_tool_api_and_streaming_metrics() -> Result<( true, "ok", &[], + None, + None, ); manager.record_api_request(1, Some(200), None, Duration::from_millis(300)); manager.record_websocket_request(Duration::from_millis(400), None); From 4d60c803ba9c703b978e89a8f6f8a4ad1866582c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 15:26:33 +0000 Subject: [PATCH 12/39] feat: cleaner TUI for sub-agents (#12327) Screenshot 2026-02-20 at 14 31 25 --- .../schema/json/EventMsg.json | 237 +++++++ .../schema/json/ServerNotification.json | 153 +++++ .../codex_app_server_protocol.schemas.json | 153 +++++ .../json/v1/ForkConversationResponse.json | 153 +++++ .../json/v1/ResumeConversationResponse.json | 153 +++++ .../v1/SessionConfiguredNotification.json | 153 +++++ .../CollabAgentInteractionEndEvent.ts | 8 + .../schema/typescript/CollabAgentRef.ts | 18 + .../typescript/CollabAgentSpawnEndEvent.ts | 8 + .../typescript/CollabAgentStatusEntry.ts | 23 + .../schema/typescript/CollabCloseEndEvent.ts | 8 + .../typescript/CollabResumeBeginEvent.ts | 10 +- .../schema/typescript/CollabResumeEndEvent.ts | 8 + .../typescript/CollabWaitingBeginEvent.ts | 5 + .../typescript/CollabWaitingEndEvent.ts | 5 + .../schema/typescript/index.ts | 2 + codex-rs/app-server-protocol/src/export.rs | 13 +- .../src/protocol/thread_history.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 4 + codex-rs/core/src/agent/control.rs | 17 + .../core/src/tools/handlers/multi_agents.rs | 106 ++++ .../src/event_processor_with_human_output.rs | 5 + .../tests/event_processor_with_json_output.rs | 3 + codex-rs/protocol/src/protocol.rs | 62 ++ codex-rs/tui/src/multi_agents.rs | 585 ++++++++++++------ ...gents__tests__collab_agent_transcript.snap | 17 + 26 files changed, 1735 insertions(+), 176 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts create mode 100644 codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index a85d02853b..33f58d18b1 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7528,6 +7681,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -7633,6 +7800,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7683,6 +7864,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -7718,6 +7906,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -7801,6 +7996,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7850,6 +8059,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7890,6 +8113,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 128f4fa60d..6a6bf2d7c7 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -808,6 +808,36 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, "CollabAgentState": { "properties": { "message": { @@ -836,6 +866,45 @@ ], "type": "string" }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "CollabAgentTool": { "enum": [ "spawnAgent", @@ -3364,6 +3433,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -3469,6 +3552,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3519,6 +3616,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -3554,6 +3658,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -3637,6 +3748,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3686,6 +3811,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3726,6 +3865,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { 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 ea4cd0bd3a..df73bf2c46 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 @@ -2029,6 +2029,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "CommandExecutionApprovalDecision": { "oneOf": [ { @@ -4592,6 +4661,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -4697,6 +4780,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4747,6 +4844,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -4782,6 +4886,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -4865,6 +4976,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4914,6 +5039,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4954,6 +5093,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index d7f1e1d837..f88d5f741b 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 9cbd492d99..e619dfe126 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index fd66f6060e..61887b4007 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts index 0596300b35..5458e06dce 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Prompt sent from the sender to the receiver. Can be empty to prevent CoT * leaking at the beginning. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts new file mode 100644 index 0000000000..cae7bf88b8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts @@ -0,0 +1,18 @@ +// 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 { ThreadId } from "./ThreadId"; + +export type CollabAgentRef = { +/** + * Thread ID of the receiver/new agent. + */ +thread_id: ThreadId, +/** + * Optional nickname assigned to an AgentControl-spawned sub-agent. + */ +agent_nickname?: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agent_role?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts index e880b5a401..34753c8e08 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the newly spawned agent, if it was created. */ new_thread_id: ThreadId | null, +/** + * Optional nickname assigned to the new agent. + */ +new_agent_nickname?: string | null, +/** + * Optional role assigned to the new agent. + */ +new_agent_role?: string | null, /** * Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the * beginning. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts new file mode 100644 index 0000000000..286d19423d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts @@ -0,0 +1,23 @@ +// 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 { AgentStatus } from "./AgentStatus"; +import type { ThreadId } from "./ThreadId"; + +export type CollabAgentStatusEntry = { +/** + * Thread ID of the receiver/new agent. + */ +thread_id: ThreadId, +/** + * Optional nickname assigned to an AgentControl-spawned sub-agent. + */ +agent_nickname?: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agent_role?: string | null, +/** + * Last known status of the agent. + */ +status: AgentStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts index 70343cbe4d..171886f1ef 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Last known status of the receiver agent reported to the sender agent before * the close. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts index b703666723..e6c1c3d5cd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts @@ -15,4 +15,12 @@ sender_thread_id: ThreadId, /** * Thread ID of the receiver. */ -receiver_thread_id: ThreadId, }; +receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts index 9eed310360..caf970ec28 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Last known status of the receiver agent reported to the sender agent after * resume. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts index 0cbe04f62b..f2f07f87ea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.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 { CollabAgentRef } from "./CollabAgentRef"; import type { ThreadId } from "./ThreadId"; export type CollabWaitingBeginEvent = { @@ -12,6 +13,10 @@ sender_thread_id: ThreadId, * Thread ID of the receivers. */ receiver_thread_ids: Array, +/** + * Optional nicknames/roles for receivers. + */ +receiver_agents?: Array, /** * ID of the waiting call. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts index 57f914c134..929d59c611 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AgentStatus } from "./AgentStatus"; +import type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; import type { ThreadId } from "./ThreadId"; export type CollabWaitingEndEvent = { @@ -13,6 +14,10 @@ sender_thread_id: ThreadId, * ID of the waiting call. */ call_id: string, +/** + * Optional receiver metadata paired with final statuses. + */ +agent_statuses?: Array, /** * Last known status of the receiver agents reported to the sender agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 79d8bdd98e..cda04def82 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -33,8 +33,10 @@ export type { ClientRequest } from "./ClientRequest"; export type { CodexErrorInfo } from "./CodexErrorInfo"; export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; +export type { CollabAgentRef } from "./CollabAgentRef"; export type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent"; export type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent"; +export type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; export type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent"; export type { CollabCloseEndEvent } from "./CollabCloseEndEvent"; export type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 2cb3f9d1f1..ec51dd1585 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -1475,7 +1475,18 @@ mod tests { .file_stem() .and_then(|stem| stem.to_str()) .is_some_and(|stem| { - stem.ends_with("Params") || stem == "InitializeCapabilities" + stem.ends_with("Params") + || stem == "InitializeCapabilities" + || matches!( + stem, + "CollabAgentRef" + | "CollabAgentStatusEntry" + | "CollabAgentSpawnEndEvent" + | "CollabAgentInteractionEndEvent" + | "CollabCloseEndEvent" + | "CollabResumeBeginEvent" + | "CollabResumeEndEvent" + ) }); let contents = fs::read_to_string(&path)?; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 52850a67e4..fce6f4f263 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2002,6 +2002,8 @@ mod tests { .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") .expect("valid receiver thread id"), + receiver_agent_nickname: None, + receiver_agent_role: None, status: AgentStatus::Completed(None), }), ]; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2e87a16844..ea4fa691d5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2059,6 +2059,8 @@ mod tests { call_id: "call-1".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, }; let item = collab_resume_begin_item(event.clone()); @@ -2080,6 +2082,8 @@ mod tests { call_id: "call-2".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, status: codex_protocol::protocol::AgentStatus::NotFound, }; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a9ddde6324..7287d25283 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -218,6 +218,23 @@ impl AgentControl { thread.agent_status().await } + pub(crate) async fn get_agent_nickname_and_role( + &self, + agent_id: ThreadId, + ) -> Option<(Option, Option)> { + let Ok(state) = self.upgrade() else { + return None; + }; + let Ok(thread) = state.get_thread(agent_id).await else { + return None; + }; + let session_source = thread.config_snapshot().await.session_source; + Some(( + session_source.get_nickname(), + session_source.get_agent_role(), + )) + } + /// Subscribe to status updates for `agent_id`, yielding the latest value and changes. pub(crate) async fn subscribe_status( &self, diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index dfc82ce52c..b0c4cc4126 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -20,8 +20,10 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -33,6 +35,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; pub struct MultiAgentHandler; @@ -171,6 +174,15 @@ mod spawn { ), Err(_) => (None, AgentStatus::NotFound), }; + let (new_agent_nickname, new_agent_role) = match new_thread_id { + Some(thread_id) => session + .services + .agent_control + .get_agent_nickname_and_role(thread_id) + .await + .unwrap_or((None, None)), + None => (None, None), + }; session .send_event( &turn, @@ -178,6 +190,8 @@ mod spawn { call_id, sender_thread_id: session.conversation_id, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, status, } @@ -228,6 +242,12 @@ mod send_input { let receiver_thread_id = agent_id(&args.id)?; let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); if args.interrupt { session .services @@ -266,6 +286,8 @@ mod send_input { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, status, } @@ -308,6 +330,12 @@ mod resume_agent { ) -> Result { let args: ResumeAgentArgs = parse_arguments(&arguments)?; let receiver_thread_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); let child_depth = next_thread_spawn_depth(&turn.session_source); if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { return Err(FunctionCallError::RespondToModel( @@ -322,6 +350,8 @@ mod resume_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), } .into(), ) @@ -352,6 +382,12 @@ mod resume_agent { None }; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((receiver_agent_nickname, receiver_agent_role)); session .send_event( &turn, @@ -359,6 +395,8 @@ mod resume_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -448,6 +486,20 @@ pub(crate) mod wait { .iter() .map(|id| agent_id(id)) .collect::, _>>()?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let (agent_nickname, agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(*receiver_thread_id) + .await + .unwrap_or((None, None)); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname, + agent_role, + }); + } // Validate timeout. // Very short timeouts encourage busy-polling loops in the orchestrator prompt and can @@ -468,6 +520,7 @@ pub(crate) mod wait { CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), call_id: call_id.clone(), } .into(), @@ -497,6 +550,10 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), statuses, } .into(), @@ -543,6 +600,7 @@ pub(crate) mod wait { // Convert payload. let statuses_map = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); let result = WaitResult { status: statuses_map.clone(), timed_out: statuses.is_empty(), @@ -555,6 +613,7 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, + agent_statuses, statuses: statuses_map, } .into(), @@ -611,6 +670,12 @@ pub mod close_agent { ) -> Result { let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(agent_id) + .await + .unwrap_or((None, None)); session .send_event( &turn, @@ -638,6 +703,8 @@ pub mod close_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), status, } .into(), @@ -664,6 +731,8 @@ pub mod close_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -687,6 +756,43 @@ fn agent_id(id: &str) -> Result { .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) } +fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(_) => { diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f77a4ec16a..6583c19f9e 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -632,6 +632,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { new_thread_id, prompt, status, + .. }) => { let success = new_thread_id.is_some() && !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -669,6 +670,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { receiver_thread_id, prompt, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -687,6 +689,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_ids, call_id, + .. }) => { ts_msg!( self, @@ -703,6 +706,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, call_id, statuses, + .. }) => { if statuses.is_empty() { ts_msg!( @@ -755,6 +759,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_id, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 35cb962ee7..c04fcdb532 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -572,6 +572,8 @@ fn collab_spawn_begin_and_end_emit_item_events() { call_id: "call-10".to_string(), sender_thread_id, new_thread_id: Some(new_thread_id), + new_agent_nickname: None, + new_agent_role: None, prompt: prompt.clone(), status: AgentStatus::Running, }), @@ -623,6 +625,7 @@ fn collab_wait_end_without_begin_synthesizes_failed_item() { EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { sender_thread_id, call_id: "call-11".to_string(), + agent_statuses: Vec::new(), statuses: statuses.clone(), }), ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a759f15129..5b3d41888c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2747,6 +2747,32 @@ pub struct CollabAgentSpawnBeginEvent { pub prompt: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentRef { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentStatusEntry { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, + /// Last known status of the agent. + pub status: AgentStatus, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct CollabAgentSpawnEndEvent { /// Identifier for the collab tool call. @@ -2755,6 +2781,12 @@ pub struct CollabAgentSpawnEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the newly spawned agent, if it was created. pub new_thread_id: Option, + /// Optional nickname assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_nickname: Option, + /// Optional role assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_role: Option, /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the /// beginning. pub prompt: String, @@ -2783,6 +2815,12 @@ pub struct CollabAgentInteractionEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT /// leaking at the beginning. pub prompt: String, @@ -2796,6 +2834,9 @@ pub struct CollabWaitingBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receivers. pub receiver_thread_ids: Vec, + /// Optional nicknames/roles for receivers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub receiver_agents: Vec, /// ID of the waiting call. pub call_id: String, } @@ -2806,6 +2847,9 @@ pub struct CollabWaitingEndEvent { pub sender_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, + /// Optional receiver metadata paired with final statuses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_statuses: Vec, /// Last known status of the receiver agents reported to the sender agent. pub statuses: HashMap, } @@ -2828,6 +2872,12 @@ pub struct CollabCloseEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent before /// the close. pub status: AgentStatus, @@ -2841,6 +2891,12 @@ pub struct CollabResumeBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -2851,6 +2907,12 @@ pub struct CollabResumeEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent after /// resume. pub status: AgentStatus, diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index e26bb8785a..57df8b8891 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -3,7 +3,9 @@ use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; use codex_core::protocol::AgentStatus; use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentRef; use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabAgentStatusEntry; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabResumeBeginEvent; use codex_core::protocol::CollabResumeEndEvent; @@ -14,282 +16,517 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use std::collections::HashMap; +use std::collections::HashSet; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +#[derive(Clone, Copy)] +struct AgentLabel<'a> { + thread_id: Option, + nickname: Option<&'a str>, + role: Option<&'a str>, +} + pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { - call_id, + call_id: _, sender_thread_id: _, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, - status, + status: _, } = ev; - let new_agent = new_thread_id - .map(|id| Span::from(id.to_string())) - .unwrap_or_else(|| Span::from("not created").dim()); - let mut details = vec![ - detail_line("call", call_id), - detail_line("agent", new_agent), - status_line(&status), - ]; + + let title = match new_thread_id { + Some(thread_id) => title_with_agent( + "Spawned", + AgentLabel { + thread_id: Some(thread_id), + nickname: new_agent_nickname.as_deref(), + role: new_agent_role.as_deref(), + }, + ), + None => title_text("Agent spawn failed"), + }; + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Agent spawned", details) + collab_event(title, details) } pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, - status, + status: _, } = ev; - let mut details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; + + let title = title_with_agent( + "Sent input to", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ); + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Input sent", details) + collab_event(title, details) } pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { - call_id, sender_thread_id: _, receiver_thread_ids, + receiver_agents, + call_id: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receivers", format_thread_ids(&receiver_thread_ids)), - ]; - collab_event("Waiting for agents", details) + let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents); + + let title = match receiver_agents.as_slice() { + [receiver] => title_with_agent("Waiting for", agent_label_from_ref(receiver)), + [] => title_text("Waiting for agents"), + _ => title_text(format!("Waiting for {} agents", receiver_agents.len())), + }; + + let details = if receiver_agents.len() > 1 { + receiver_agents + .iter() + .map(|receiver| agent_label_line(agent_label_from_ref(receiver))) + .collect() + } else { + Vec::new() + }; + + collab_event(title, details) } pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { - call_id, + call_id: _, sender_thread_id: _, + agent_statuses, statuses, } = ev; - let mut details = vec![detail_line("call", call_id)]; - details.extend(wait_complete_lines(&statuses)); - collab_event("Wait complete", details) + let details = wait_complete_lines(&statuses, &agent_statuses); + collab_event(title_text("Finished waiting"), details) } pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, - status, + receiver_agent_nickname, + receiver_agent_role, + status: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent closed", details) + + collab_event( + title_with_agent( + "Closed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell { let CollabResumeBeginEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - ]; - collab_event("Resuming agent", details) + + collab_event( + title_with_agent( + "Resuming", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell { let CollabResumeEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent resumed", details) + + collab_event( + title_with_agent( + "Resumed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + vec![status_summary_line(&status)], + ) } -fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { - let title = title.into(); - let mut lines: Vec> = - vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()]; +fn collab_event(title: Line<'static>, details: Vec>) -> PlainHistoryCell { + let mut lines: Vec> = vec![title]; if !details.is_empty() { lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); } PlainHistoryCell::new(lines) } -fn detail_line(label: &str, value: impl Into>) -> Line<'static> { - vec![Span::from(format!("{label}: ")).dim(), value.into()].into() +fn title_text(title: impl Into) -> Line<'static> { + title_spans_line(vec![Span::from(title.into()).bold()]) } -fn status_line(status: &AgentStatus) -> Line<'static> { - detail_line("status", status_span(status)) +fn title_with_agent(prefix: &str, agent: AgentLabel<'_>) -> Line<'static> { + let mut spans = vec![Span::from(format!("{prefix} ")).bold()]; + spans.extend(agent_label_spans(agent)); + title_spans_line(spans) } -fn status_span(status: &AgentStatus) -> Span<'static> { - match status { - AgentStatus::PendingInit => Span::from("pending init").dim(), - AgentStatus::Running => Span::from("running").cyan().bold(), - AgentStatus::Completed(_) => Span::from("completed").green(), - AgentStatus::Errored(_) => Span::from("errored").red(), - AgentStatus::Shutdown => Span::from("shutdown").dim(), - AgentStatus::NotFound => Span::from("not found").red(), +fn title_spans_line(mut spans: Vec>) -> Line<'static> { + let mut title = Vec::with_capacity(spans.len() + 1); + title.push(Span::from("• ").dim()); + title.append(&mut spans); + title.into() +} + +fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> { + AgentLabel { + thread_id: Some(agent.thread_id), + nickname: agent.agent_nickname.as_deref(), + role: agent.agent_role.as_deref(), } } +fn agent_label_line(agent: AgentLabel<'_>) -> Line<'static> { + agent_label_spans(agent).into() +} + +fn agent_label_spans(agent: AgentLabel<'_>) -> Vec> { + let mut spans = Vec::new(); + let nickname = agent + .nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let role = agent.role.map(str::trim).filter(|role| !role.is_empty()); + + if let Some(nickname) = nickname { + spans.push(Span::from(nickname.to_string()).light_blue().bold()); + } else if let Some(thread_id) = agent.thread_id { + spans.push(Span::from(thread_id.to_string()).dim()); + } else { + spans.push(Span::from("agent").dim()); + } + + if let Some(role) = role { + spans.push(Span::from(" ").dim()); + spans.push(Span::from(format!("[{role}]")).dim()); + } + + spans +} + fn prompt_line(prompt: &str) -> Option> { let trimmed = prompt.trim(); if trimmed.is_empty() { None } else { - Some(detail_line( - "prompt", - Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(), - )) + Some(Line::from(Span::from(truncate_text( + trimmed, + COLLAB_PROMPT_PREVIEW_GRAPHEMES, + )))) } } -fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> { - if ids.is_empty() { - return Span::from("none").dim(); +fn merge_wait_receivers( + receiver_thread_ids: &[ThreadId], + mut receiver_agents: Vec, +) -> Vec { + if receiver_agents.is_empty() { + return receiver_thread_ids + .iter() + .map(|thread_id| CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }) + .collect(); } - let joined = ids + + let mut seen = receiver_agents .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - Span::from(joined) -} - -fn wait_complete_lines(statuses: &HashMap) -> Vec> { - if statuses.is_empty() { - return vec![detail_line("agents", Span::from("none").dim())]; - } - - let mut pending_init = 0usize; - let mut running = 0usize; - let mut completed = 0usize; - let mut errored = 0usize; - let mut shutdown = 0usize; - let mut not_found = 0usize; - for status in statuses.values() { - match status { - AgentStatus::PendingInit => pending_init += 1, - AgentStatus::Running => running += 1, - AgentStatus::Completed(_) => completed += 1, - AgentStatus::Errored(_) => errored += 1, - AgentStatus::Shutdown => shutdown += 1, - AgentStatus::NotFound => not_found += 1, + .map(|agent| agent.thread_id) + .collect::>(); + for thread_id in receiver_thread_ids { + if seen.insert(*thread_id) { + receiver_agents.push(CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }); } } + receiver_agents +} - let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()]; - push_status_count( - &mut summary, - pending_init, - "pending init", - ratatui::prelude::Stylize::dim, - ); - push_status_count(&mut summary, running, "running", |span| span.cyan().bold()); - push_status_count( - &mut summary, - completed, - "completed", - ratatui::prelude::Stylize::green, - ); - push_status_count( - &mut summary, - errored, - "errored", - ratatui::prelude::Stylize::red, - ); - push_status_count( - &mut summary, - shutdown, - "shutdown", - ratatui::prelude::Stylize::dim, - ); - push_status_count( - &mut summary, - not_found, - "not found", - ratatui::prelude::Stylize::red, - ); +fn wait_complete_lines( + statuses: &HashMap, + agent_statuses: &[CollabAgentStatusEntry], +) -> Vec> { + if statuses.is_empty() && agent_statuses.is_empty() { + return vec![Line::from(Span::from("No agents completed yet").dim())]; + } - let mut entries: Vec<(String, &AgentStatus)> = statuses - .iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let entries = if agent_statuses.is_empty() { + let mut entries = statuses + .iter() + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries + } else { + let mut entries = agent_statuses.to_vec(); + let seen = entries + .iter() + .map(|entry| entry.thread_id) + .collect::>(); + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries + }; - let mut lines = Vec::with_capacity(entries.len() + 1); - lines.push(detail_line_spans("agents", summary)); - lines.extend(entries.into_iter().map(|(thread_id, status)| { - let mut spans = vec![ - Span::from(thread_id).dim(), - Span::from(" ").dim(), - status_span(status), - ]; - match status { - AgentStatus::Completed(Some(message)) => { + entries + .into_iter() + .map(|entry| { + let CollabAgentStatusEntry { + thread_id, + agent_nickname, + agent_role, + status, + } = entry; + let mut spans = agent_label_spans(AgentLabel { + thread_id: Some(thread_id), + nickname: agent_nickname.as_deref(), + role: agent_role.as_deref(), + }); + spans.push(Span::from(": ").dim()); + spans.extend(status_summary_spans(&status)); + spans.into() + }) + .collect() +} + +fn status_summary_line(status: &AgentStatus) -> Line<'static> { + status_summary_spans(status).into() +} + +fn status_summary_spans(status: &AgentStatus) -> Vec> { + match status { + AgentStatus::PendingInit => vec![Span::from("Pending init").dim()], + AgentStatus::Running => vec![Span::from("Running").cyan().bold()], + AgentStatus::Completed(message) => { + let mut spans = vec![Span::from("Completed").green()]; + if let Some(message) = message.as_ref() { let message_preview = truncate_text( &message.split_whitespace().collect::>().join(" "), COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, ); - spans.push(Span::from(": ").dim()); - spans.push(Span::from(message_preview)); + if !message_preview.is_empty() { + spans.push(Span::from(" - ").dim()); + spans.push(Span::from(message_preview)); + } } - AgentStatus::Errored(error) => { - let error_preview = truncate_text( - &error.split_whitespace().collect::>().join(" "), - COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, - ); - spans.push(Span::from(": ").dim()); + spans + } + AgentStatus::Errored(error) => { + let mut spans = vec![Span::from("Error").red()]; + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + if !error_preview.is_empty() { + spans.push(Span::from(" - ").dim()); spans.push(Span::from(error_preview).dim()); } - _ => {} + spans } - spans.into() - })); - lines + AgentStatus::Shutdown => vec![Span::from("Shutdown").dim()], + AgentStatus::NotFound => vec![Span::from("Not found").red()], + } } -fn push_status_count( - spans: &mut Vec>, - count: usize, - label: &'static str, - style: impl FnOnce(Span<'static>) -> Span<'static>, -) { - if count == 0 { - return; +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::HistoryCell; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + use ratatui::style::Modifier; + + #[test] + fn collab_events_snapshot() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003") + .expect("valid bob thread id"); + + let spawn = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: "Compute 11! and reply with just the integer result.".to_string(), + status: AgentStatus::PendingInit, + }); + + let send = interaction_end(CollabAgentInteractionEndEvent { + call_id: "call-send".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + prompt: "Please continue and return the answer only.".to_string(), + status: AgentStatus::Running, + }); + + let waiting = waiting_begin(CollabWaitingBeginEvent { + sender_thread_id, + receiver_thread_ids: vec![robie_id], + receiver_agents: vec![CollabAgentRef { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + }], + call_id: "call-wait".to_string(), + }); + + let mut statuses = HashMap::new(); + statuses.insert( + robie_id, + AgentStatus::Completed(Some("39916800".to_string())), + ); + statuses.insert(bob_id, AgentStatus::Errored("tool timeout".to_string())); + let finished = waiting_end(CollabWaitingEndEvent { + sender_thread_id, + call_id: "call-wait".to_string(), + agent_statuses: vec![ + CollabAgentStatusEntry { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }, + CollabAgentStatusEntry { + thread_id: bob_id, + agent_nickname: Some("Bob".to_string()), + agent_role: Some("worker".to_string()), + status: AgentStatus::Errored("tool timeout".to_string()), + }, + ], + statuses, + }); + + let close = close_end(CollabCloseEndEvent { + call_id: "call-close".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }); + + let snapshot = [spawn, send, waiting, finished, close] + .iter() + .map(cell_to_text) + .collect::>() + .join("\n\n"); + assert_snapshot!("collab_agent_transcript", snapshot); } - spans.push(Span::from(" · ").dim()); - spans.push(style(Span::from(format!("{count} {label}")))); -} + #[test] + fn title_styles_nickname_and_role() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let cell = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: String::new(), + status: AgentStatus::PendingInit, + }); -fn detail_line_spans(label: &str, mut value: Vec>) -> Line<'static> { - let mut spans = Vec::with_capacity(value.len() + 1); - spans.push(Span::from(format!("{label}: ")).dim()); - spans.append(&mut value); - spans.into() + let lines = cell.display_lines(200); + let title = &lines[0]; + assert_eq!(title.spans[2].content.as_ref(), "Robie"); + assert_eq!(title.spans[2].style.fg, Some(Color::LightBlue)); + assert!(title.spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(title.spans[4].content.as_ref(), "[explorer]"); + assert!(title.spans[4].style.add_modifier.contains(Modifier::DIM)); + } + + fn cell_to_text(cell: &PlainHistoryCell) -> String { + cell.display_lines(200) + .iter() + .map(line_to_text) + .collect::>() + .join("\n") + } + + fn line_to_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .join("") + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap new file mode 100644 index 0000000000..19001a70df --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/multi_agents.rs +expression: snapshot +--- +• Spawned Robie [explorer] + └ Compute 11! and reply with just the integer result. + +• Sent input to Robie [explorer] + └ Please continue and return the answer only. + +• Waiting for Robie [explorer] + +• Finished waiting + └ Robie [explorer]: Completed - 39916800 + Bob [worker]: Error - tool timeout + +• Closed Robie [explorer] From 5a30cd3f92f28ba9a32b38c6d277b377493a253b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 15:40:34 +0000 Subject: [PATCH 13/39] feat: better agent picker in TUI (#12332) Screenshot 2026-02-20 at 15 04 52 --- codex-rs/tui/src/app.rs | 94 +++++++++++++++++-- ...i__app__tests__agent_picker_item_name.snap | 9 ++ 2 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a46a927fbc..5898059644 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -808,38 +808,57 @@ impl App { async fn open_agent_picker(&mut self) { let thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); + let mut agent_threads = Vec::new(); for thread_id in thread_ids { - if self.server.get_thread(thread_id).await.is_err() { - self.thread_event_channels.remove(&thread_id); + match self.server.get_thread(thread_id).await { + Ok(thread) => { + let session_source = thread.config_snapshot().await.session_source; + agent_threads.push(( + thread_id, + session_source.get_nickname(), + session_source.get_agent_role(), + )); + } + Err(_) => { + self.thread_event_channels.remove(&thread_id); + } } } - if self.thread_event_channels.is_empty() { + if agent_threads.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); return; } - let mut thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); - thread_ids.sort_by_key(ToString::to_string); + agent_threads.sort_by(|(left, ..), (right, ..)| left.to_string().cmp(&right.to_string())); let mut initial_selected_idx = None; - let items: Vec = thread_ids + let items: Vec = agent_threads .iter() .enumerate() - .map(|(idx, thread_id)| { + .map(|(idx, (thread_id, agent_nickname, agent_role))| { if self.active_thread_id == Some(*thread_id) { initial_selected_idx = Some(idx); } let id = *thread_id; + let is_primary = self.primary_thread_id == Some(*thread_id); + let name = format_agent_picker_item_name( + *thread_id, + agent_nickname.as_deref(), + agent_role.as_deref(), + is_primary, + ); + let uuid = thread_id.to_string(); SelectionItem { - name: thread_id.to_string(), + name: name.clone(), + description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { tx.send(AppEvent::SelectAgentThread(id)); })], dismiss_on_select: true, - search_value: Some(thread_id.to_string()), + search_value: Some(format!("{name} {uuid}")), ..Default::default() } }) @@ -2892,6 +2911,28 @@ impl App { } } +fn format_agent_picker_item_name( + _thread_id: ThreadId, + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -3072,6 +3113,41 @@ mod tests { Ok(()) } + #[test] + fn agent_picker_item_name_snapshot() { + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id"); + let snapshot = [ + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), true), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, Some("Robie"), None, false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, None, Some("explorer"), false), + thread_id + ), + format!( + "{} | {}", + format_agent_picker_item_name(thread_id, None, None, false), + thread_id + ), + ] + .join("\n"); + assert_snapshot!("agent_picker_item_name", snapshot); + } + #[tokio::test] async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap new file mode 100644 index 0000000000..0337f424a8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__agent_picker_item_name.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app.rs +expression: snapshot +--- +Main [default] | 00000000-0000-0000-0000-000000000123 +Robie [explorer] | 00000000-0000-0000-0000-000000000123 +Robie | 00000000-0000-0000-0000-000000000123 +[explorer] | 00000000-0000-0000-0000-000000000123 +Agent | 00000000-0000-0000-0000-000000000123 From fd67aba114f7f2248e7e563b25d0ee9de1359717 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 17:21:45 +0000 Subject: [PATCH 14/39] feat: do not enqueue phase 2 if not necessary (#12344) --- codex-rs/state/src/runtime.rs | 116 +++++++++++++++++++++++-- codex-rs/state/src/runtime/memories.rs | 11 ++- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 3635bcb865..f027cac6e8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -2069,7 +2069,7 @@ WHERE kind = 'memory_stage1' } #[tokio::test] - async fn mark_stage1_job_succeeded_no_output_tracks_watermark_without_persisting_output() { + async fn mark_stage1_job_succeeded_no_output_skips_phase2_when_output_was_already_absent() { let codex_home = unique_temp_dir(); let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) .await @@ -2122,23 +2122,129 @@ WHERE kind = 'memory_stage1' .expect("claim stage1 up-to-date"); assert_eq!(up_to_date, Stage1JobClaimOutcome::SkippedUpToDate); + let global_job_row_count = sqlx::query("SELECT COUNT(*) AS count FROM jobs WHERE kind = ?") + .bind("memory_consolidate_global") + .fetch_one(runtime.pool.as_ref()) + .await + .expect("load phase2 job row count") + .try_get::("count") + .expect("phase2 job row count"); + assert_eq!( + global_job_row_count, 0, + "no-output without an existing stage1 output should not enqueue phase2" + ); + let claim_phase2 = runtime .try_claim_global_phase2_job(owner, 3600) .await .expect("claim phase2"); + assert_eq!( + claim_phase2, + Phase2JobClaimOutcome::SkippedNotDirty, + "phase2 should remain clean when no-output deleted nothing" + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn mark_stage1_job_succeeded_no_output_enqueues_phase2_when_deleting_output() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id"); + let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); + let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id"); + runtime + .upsert_thread(&test_thread_metadata( + &codex_home, + thread_id, + codex_home.join("workspace"), + )) + .await + .expect("upsert thread"); + + let first_claim = runtime + .try_claim_stage1_job(thread_id, owner, 100, 3600, 64) + .await + .expect("claim initial stage1"); + let first_token = match first_claim { + Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, + other => panic!("unexpected initial stage1 claim outcome: {other:?}"), + }; + assert!( + runtime + .mark_stage1_job_succeeded(thread_id, first_token.as_str(), 100, "raw", "sum", None) + .await + .expect("mark initial stage1 succeeded"), + "initial stage1 success should create stage1 output" + ); + + let phase2_claim = runtime + .try_claim_global_phase2_job(owner, 3600) + .await + .expect("claim phase2 after initial output"); + let (phase2_token, phase2_input_watermark) = match phase2_claim { + Phase2JobClaimOutcome::Claimed { + ownership_token, + input_watermark, + } => (ownership_token, input_watermark), + other => panic!("unexpected phase2 claim after initial output: {other:?}"), + }; + assert_eq!(phase2_input_watermark, 100); + assert!( + runtime + .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark) + .await + .expect("mark initial phase2 succeeded"), + "initial phase2 success should clear global dirty state" + ); + + let no_output_claim = runtime + .try_claim_stage1_job(thread_id, owner_b, 101, 3600, 64) + .await + .expect("claim stage1 for no-output delete"); + let no_output_token = match no_output_claim { + Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token, + other => panic!("unexpected no-output stage1 claim outcome: {other:?}"), + }; + assert!( + runtime + .mark_stage1_job_succeeded_no_output(thread_id, no_output_token.as_str()) + .await + .expect("mark stage1 no-output after existing output"), + "no-output should succeed when deleting an existing stage1 output" + ); + + let output_row_count = + sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?") + .bind(thread_id.to_string()) + .fetch_one(runtime.pool.as_ref()) + .await + .expect("load stage1 output count after delete") + .try_get::("count") + .expect("stage1 output count"); + assert_eq!(output_row_count, 0); + + let claim_phase2 = runtime + .try_claim_global_phase2_job(owner, 3600) + .await + .expect("claim phase2 after no-output deletion"); let (phase2_token, phase2_input_watermark) = match claim_phase2 { Phase2JobClaimOutcome::Claimed { ownership_token, input_watermark, } => (ownership_token, input_watermark), - other => panic!("unexpected phase2 claim outcome after no-output success: {other:?}"), + other => panic!("unexpected phase2 claim after no-output deletion: {other:?}"), }; - assert_eq!(phase2_input_watermark, 100); + assert_eq!(phase2_input_watermark, 101); assert!( runtime - .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark,) + .mark_global_phase2_job_succeeded(phase2_token.as_str(), phase2_input_watermark) .await - .expect("mark phase2 succeeded after no-output") + .expect("mark phase2 succeeded after no-output delete") ); let _ = tokio::fs::remove_dir_all(codex_home).await; diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index f2a50e3c99..bad5679eac 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -498,7 +498,7 @@ WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at /// - sets `status='done'` and `last_success_watermark = input_watermark` /// - deletes any existing `stage1_outputs` row for the thread /// - enqueues/advances the global phase-2 job watermark using the claimed - /// `input_watermark` + /// `input_watermark` only when deleting an existing `stage1_outputs` row pub async fn mark_stage1_job_succeeded_no_output( &self, thread_id: ThreadId, @@ -548,7 +548,7 @@ WHERE kind = ? AND job_key = ? AND ownership_token = ? .await? .try_get::("input_watermark")?; - sqlx::query( + let deleted_rows = sqlx::query( r#" DELETE FROM stage1_outputs WHERE thread_id = ? @@ -556,9 +556,12 @@ WHERE thread_id = ? ) .bind(thread_id.as_str()) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); - enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?; + if deleted_rows > 0 { + enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?; + } tx.commit().await?; Ok(true) From 5b712460017b708334b8f4e85065dbeb70d7de9f Mon Sep 17 00:00:00 2001 From: Yaroslav Volovich Date: Fri, 20 Feb 2026 17:52:21 +0000 Subject: [PATCH 15/39] fix: simplify macOS sleep inhibitor FFI (#12340) Summary - simplify the macOS sleep inhibitor FFI by replacing `dlopen` / `dlsym` / `transmute` with normal IOKit extern calls and `SAFETY` comments - switch to cfg-selected platform implementations (`imp::SleepInhibitor`) instead of `Box` - check in minimal IOKit bindings generated with `bindgen` and include them from the macOS backend - enable direct IOKit linkage in Bazel macOS builds by registering `IOKit` in the Bazel `osx.framework(...)` toolchain extension list - update `Cargo.lock` and `MODULE.bazel.lock` after removing the build-time `bindgen` dependency path Testing - `just fmt` - `cargo clippy -p codex-utils-sleep-inhibitor --all-targets -- -D warnings` - `cargo test -p codex-utils-sleep-inhibitor` - `bazel test //codex-rs/utils/sleep-inhibitor:all --test_output=errors` - `just bazel-lock-update` - `just bazel-lock-check` Context - follow-up to #11711 addressing Ryan's review comments - `bindgen` is used to generate the checked-in bindings file, but not at build time --- MODULE.bazel | 1 + codex-rs/Cargo.lock | 21 +- codex-rs/utils/sleep-inhibitor/Cargo.toml | 1 - codex-rs/utils/sleep-inhibitor/src/dummy.rs | 12 +- .../sleep-inhibitor/src/iokit_bindings.rs | 27 +++ codex-rs/utils/sleep-inhibitor/src/lib.rs | 25 +-- codex-rs/utils/sleep-inhibitor/src/macos.rs | 107 ++++++++++ .../sleep-inhibitor/src/macos_inhibitor.rs | 192 ------------------ 8 files changed, 159 insertions(+), 227 deletions(-) create mode 100644 codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs create mode 100644 codex-rs/utils/sleep-inhibitor/src/macos.rs delete mode 100644 codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs diff --git a/MODULE.bazel b/MODULE.bazel index 35dd575006..821e41878f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -20,6 +20,7 @@ osx.framework(name = "CFNetwork") osx.framework(name = "FontServices") osx.framework(name = "Foundation") osx.framework(name = "ImageIO") +osx.framework(name = "IOKit") osx.framework(name = "Kernel") osx.framework(name = "OSLog") osx.framework(name = "Security") diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 20099b02eb..8d7edc4678 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -423,7 +423,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -2451,7 +2451,6 @@ name = "codex-utils-sleep-inhibitor" version = "0.0.0" dependencies = [ "core-foundation 0.9.4", - "libc", "tracing", ] @@ -3241,7 +3240,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3486,7 +3485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4879,7 +4878,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5587,7 +5586,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6153,7 +6152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -6714,7 +6713,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7540,7 +7539,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8917,7 +8916,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10277,7 +10276,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/utils/sleep-inhibitor/Cargo.toml b/codex-rs/utils/sleep-inhibitor/Cargo.toml index 703433d819..f691b191e1 100644 --- a/codex-rs/utils/sleep-inhibitor/Cargo.toml +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -9,5 +9,4 @@ workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -libc = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/utils/sleep-inhibitor/src/dummy.rs b/codex-rs/utils/sleep-inhibitor/src/dummy.rs index 767091ea93..a0be0dc650 100644 --- a/codex-rs/utils/sleep-inhibitor/src/dummy.rs +++ b/codex-rs/utils/sleep-inhibitor/src/dummy.rs @@ -1,16 +1,12 @@ -use crate::PlatformSleepInhibitor; - #[derive(Debug, Default)] -pub(crate) struct DummySleepInhibitor; +pub(crate) struct SleepInhibitor; -impl DummySleepInhibitor { +impl SleepInhibitor { pub(crate) fn new() -> Self { Self } -} -impl PlatformSleepInhibitor for DummySleepInhibitor { - fn acquire(&mut self) {} + pub(crate) fn acquire(&mut self) {} - fn release(&mut self) {} + pub(crate) fn release(&mut self) {} } diff --git a/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs b/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs new file mode 100644 index 0000000000..50fd7fe510 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/iokit_bindings.rs @@ -0,0 +1,27 @@ +/* automatically generated by rust-bindgen 0.71.1 */ + +pub const kIOReturnSuccess: u32 = 0; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct __CFString { + _unused: [u8; 0], +} +pub type CFStringRef = *const __CFString; +pub type kern_return_t = ::std::os::raw::c_int; +pub type IOReturn = kern_return_t; +pub type IOPMAssertionID = u32; +pub type IOPMAssertionLevel = u32; +pub const kIOPMAssertionLevelOff: _bindgen_ty_36 = 0; +pub const kIOPMAssertionLevelOn: _bindgen_ty_36 = 255; +pub type _bindgen_ty_36 = ::std::os::raw::c_uint; +unsafe extern "C" { + pub fn IOPMAssertionRelease(AssertionID: IOPMAssertionID) -> IOReturn; +} +unsafe extern "C" { + pub fn IOPMAssertionCreateWithName( + AssertionType: CFStringRef, + AssertionLevel: IOPMAssertionLevel, + AssertionName: CFStringRef, + AssertionID: *mut IOPMAssertionID, + ) -> IOReturn; +} diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs index 0f2f32df64..810fb170a2 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -6,31 +6,26 @@ #[cfg(not(target_os = "macos"))] mod dummy; #[cfg(target_os = "macos")] -mod macos_inhibitor; +mod macos; -use std::fmt::Debug; +#[cfg(not(target_os = "macos"))] +use dummy as imp; +#[cfg(target_os = "macos")] +use macos as imp; /// Keeps the machine awake while a turn is in progress when enabled. #[derive(Debug)] pub struct SleepInhibitor { enabled: bool, - platform: Box, -} - -pub(crate) trait PlatformSleepInhibitor: Debug { - fn acquire(&mut self); - fn release(&mut self); + platform: imp::SleepInhibitor, } impl SleepInhibitor { pub fn new(enabled: bool) -> Self { - #[cfg(target_os = "macos")] - let platform: Box = - Box::new(macos_inhibitor::MacOsSleepInhibitor::new()); - #[cfg(not(target_os = "macos"))] - let platform: Box = Box::new(dummy::DummySleepInhibitor::new()); - - Self { enabled, platform } + Self { + enabled, + platform: imp::SleepInhibitor::new(), + } } /// Update the active turn state; turns sleep prevention on/off as needed. diff --git a/codex-rs/utils/sleep-inhibitor/src/macos.rs b/codex-rs/utils/sleep-inhibitor/src/macos.rs new file mode 100644 index 0000000000..b8aaf29ab3 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/macos.rs @@ -0,0 +1,107 @@ +use core_foundation::base::TCFType; +use core_foundation::string::CFString; +use tracing::warn; + +#[allow( + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + clippy::all +)] +mod iokit { + #[link(name = "IOKit", kind = "framework")] + unsafe extern "C" {} + + include!("iokit_bindings.rs"); +} + +type IOPMAssertionID = iokit::IOPMAssertionID; +type IOPMAssertionLevel = iokit::IOPMAssertionLevel; +type IOReturn = iokit::IOReturn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +// Apple exposes this assertion type as a `CFSTR(...)` macro, so bindgen cannot generate a +// reusable `CFStringRef` constant for it. +const ASSERTION_TYPE_PREVENT_USER_IDLE_SYSTEM_SLEEP: &str = "PreventUserIdleSystemSleep"; + +#[derive(Debug, Default)] +pub(crate) struct SleepInhibitor { + assertion: Option, +} + +impl SleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if self.assertion.is_some() { + return; + } + + match MacSleepAssertion::create(ASSERTION_REASON) { + Ok(assertion) => { + self.assertion = Some(assertion); + } + Err(error) => { + warn!( + iokit_error = error, + "Failed to create macOS sleep-prevention assertion" + ); + } + } + } + + pub(crate) fn release(&mut self) { + self.assertion = None; + } +} + +#[derive(Debug)] +struct MacSleepAssertion { + id: IOPMAssertionID, +} + +impl MacSleepAssertion { + fn create(name: &str) -> Result { + let assertion_type = CFString::new(ASSERTION_TYPE_PREVENT_USER_IDLE_SYSTEM_SLEEP); + let assertion_name = CFString::new(name); + let mut id: IOPMAssertionID = 0; + // `core-foundation` and the generated bindings each declare an opaque `__CFString` type, + // so we cast to the bindgen pointer aliases before crossing the FFI boundary. + let assertion_type_ref: iokit::CFStringRef = assertion_type.as_concrete_TypeRef().cast(); + let assertion_name_ref: iokit::CFStringRef = assertion_name.as_concrete_TypeRef().cast(); + let result = unsafe { + // SAFETY: `assertion_type_ref` and `assertion_name_ref` are valid `CFStringRef`s and + // `&mut id` is a valid out-pointer for `IOPMAssertionCreateWithName` to initialize. + iokit::IOPMAssertionCreateWithName( + assertion_type_ref, + iokit::kIOPMAssertionLevelOn as IOPMAssertionLevel, + assertion_name_ref, + &mut id, + ) + }; + if result == iokit::kIOReturnSuccess as IOReturn { + Ok(Self { id }) + } else { + Err(result) + } + } +} + +impl Drop for MacSleepAssertion { + fn drop(&mut self) { + let result = unsafe { + // SAFETY: `self.id` was returned by `IOPMAssertionCreateWithName` and this `Drop` + // implementation releases it exactly once when the owning assertion is dropped. + iokit::IOPMAssertionRelease(self.id) + }; + if result != iokit::kIOReturnSuccess as IOReturn { + warn!( + iokit_error = result, + "Failed to release macOS sleep-prevention assertion" + ); + } + } +} diff --git a/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs deleted file mode 100644 index e788705e1c..0000000000 --- a/codex-rs/utils/sleep-inhibitor/src/macos_inhibitor.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::PlatformSleepInhibitor; -use core_foundation::base::TCFType; -use core_foundation::string::CFString; -use core_foundation::string::CFStringRef; -use std::fmt::Debug; -use std::sync::OnceLock; -use tracing::warn; - -const ASSERTION_REASON: &str = "Codex is running an active turn"; -const MACOS_IDLE_SLEEP_ASSERTION_TYPE: &str = "PreventUserIdleSystemSleep"; -const IOKIT_FRAMEWORK_BINARY: &[u8] = b"/System/Library/Frameworks/IOKit.framework/IOKit\0"; -const IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL: &[u8] = b"IOPMAssertionCreateWithName\0"; -const IOPM_ASSERTION_RELEASE_SYMBOL: &[u8] = b"IOPMAssertionRelease\0"; -const IOKIT_ASSERTION_API_UNAVAILABLE: &str = "IOKit power assertion APIs are unavailable"; - -type IOPMAssertionReleaseFn = unsafe extern "C" fn(assertion_id: IOPMAssertionID) -> IOReturn; -type IOPMAssertionID = u32; -type IOPMAssertionLevel = u32; -type IOReturn = i32; - -const K_IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255; -const K_IORETURN_SUCCESS: IOReturn = 0; - -#[derive(Debug, Default)] -pub(crate) struct MacOsSleepInhibitor { - assertion: Option, -} - -impl MacOsSleepInhibitor { - pub(crate) fn new() -> Self { - Self { - ..Default::default() - } - } -} - -impl PlatformSleepInhibitor for MacOsSleepInhibitor { - fn acquire(&mut self) { - if self.assertion.is_some() { - return; - } - - match MacSleepAssertion::create(ASSERTION_REASON) { - Ok(assertion) => { - self.assertion = Some(assertion); - } - Err(error) => match error { - MacSleepAssertionError::ApiUnavailable(reason) => { - warn!(reason, "Failed to create macOS sleep-prevention assertion"); - } - MacSleepAssertionError::Iokit(code) => { - warn!( - iokit_error = code, - "Failed to create macOS sleep-prevention assertion" - ); - } - }, - } - } - - fn release(&mut self) { - self.assertion = None; - } -} - -#[derive(Debug)] -struct MacSleepAssertion { - id: IOPMAssertionID, -} - -impl MacSleepAssertion { - fn create(name: &str) -> Result { - let Some(api) = MacSleepApi::get() else { - return Err(MacSleepAssertionError::ApiUnavailable( - IOKIT_ASSERTION_API_UNAVAILABLE, - )); - }; - - let assertion_type = CFString::new(MACOS_IDLE_SLEEP_ASSERTION_TYPE); - let assertion_name = CFString::new(name); - let mut id: IOPMAssertionID = 0; - let result = unsafe { - (api.create_with_name)( - assertion_type.as_concrete_TypeRef(), - K_IOPM_ASSERTION_LEVEL_ON, - assertion_name.as_concrete_TypeRef(), - &mut id, - ) - }; - if result == K_IORETURN_SUCCESS { - Ok(Self { id }) - } else { - Err(MacSleepAssertionError::Iokit(result)) - } - } -} - -impl Drop for MacSleepAssertion { - fn drop(&mut self) { - let Some(api) = MacSleepApi::get() else { - warn!( - reason = IOKIT_ASSERTION_API_UNAVAILABLE, - "Failed to release macOS sleep-prevention assertion" - ); - return; - }; - - let result = unsafe { (api.release)(self.id) }; - if result != K_IORETURN_SUCCESS { - warn!( - iokit_error = result, - "Failed to release macOS sleep-prevention assertion" - ); - } - } -} - -#[derive(Debug, Clone, Copy)] -enum MacSleepAssertionError { - ApiUnavailable(&'static str), - Iokit(IOReturn), -} - -type IOPMAssertionCreateWithNameFn = unsafe extern "C" fn( - assertion_type: CFStringRef, - assertion_level: IOPMAssertionLevel, - assertion_name: CFStringRef, - assertion_id: *mut IOPMAssertionID, -) -> IOReturn; - -struct MacSleepApi { - // Keep the dlopen handle alive for the lifetime of the loaded symbols. - // This prevents accidental dlclose while function pointers are in use. - _iokit_handle: usize, - create_with_name: IOPMAssertionCreateWithNameFn, - release: IOPMAssertionReleaseFn, -} - -impl MacSleepApi { - fn get() -> Option<&'static Self> { - static API: OnceLock> = OnceLock::new(); - API.get_or_init(Self::load).as_ref() - } - - fn load() -> Option { - let handle = unsafe { - libc::dlopen( - IOKIT_FRAMEWORK_BINARY.as_ptr().cast(), - libc::RTLD_LOCAL | libc::RTLD_LAZY, - ) - }; - if handle.is_null() { - warn!(framework = "IOKit", "Failed to open IOKit framework"); - return None; - } - - let create_with_name = unsafe { - libc::dlsym( - handle, - IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL.as_ptr().cast(), - ) - }; - if create_with_name.is_null() { - warn!( - symbol = "IOPMAssertionCreateWithName", - "Failed to load IOKit symbol" - ); - let _ = unsafe { libc::dlclose(handle) }; - return None; - } - - let release = unsafe { libc::dlsym(handle, IOPM_ASSERTION_RELEASE_SYMBOL.as_ptr().cast()) }; - if release.is_null() { - warn!( - symbol = "IOPMAssertionRelease", - "Failed to load IOKit symbol" - ); - let _ = unsafe { libc::dlclose(handle) }; - return None; - } - - let create_with_name: IOPMAssertionCreateWithNameFn = - unsafe { std::mem::transmute(create_with_name) }; - let release: IOPMAssertionReleaseFn = unsafe { std::mem::transmute(release) }; - - Some(Self { - _iokit_handle: handle as usize, - create_with_name, - release, - }) - } -} From 035c4c30bb0a9468ec35ad8a5ae693a0fee6ae46 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 17:53:51 +0000 Subject: [PATCH 16/39] fix: nick name at thread/read (#12347) --- .../app-server/src/codex_message_processor.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 44444494df..60c9c4638a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6798,6 +6798,12 @@ pub(crate) async fn read_summary_from_rollout( meta: session_meta, git, } = session_meta_line; + let mut session_meta = session_meta; + session_meta.source = with_thread_spawn_agent_metadata( + session_meta.source.clone(), + session_meta.agent_nickname.clone(), + session_meta.agent_role.clone(), + ); let created_at = if session_meta.timestamp.is_empty() { None @@ -7174,6 +7180,52 @@ mod tests { Ok(()) } + #[tokio::test] + async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_nickname: None, + agent_role: None, + }), + agent_nickname: Some("atlas".to_string()), + agent_role: Some("explorer".to_string()), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + let thread = summary_to_thread(summary); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + #[test] fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; From 86803ca9bf00bc984d7f4a58342c053e3173db84 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 20 Feb 2026 10:09:46 -0800 Subject: [PATCH 17/39] Reuse connection between turns (#12294) Add a pool of one to the model client to reuse connections across turns. --- .../src/endpoint/responses_websocket.rs | 13 +++++++++ codex-rs/core/src/client.rs | 29 ++++++++++++++++++- .../core/tests/suite/client_websockets.rs | 28 ++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index aa559e9836..bdd32fbd5a 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -175,6 +175,19 @@ pub struct ResponsesWebsocketConnection { telemetry: Option>, } +impl std::fmt::Debug for ResponsesWebsocketConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResponsesWebsocketConnection") + .field("stream", &"") + .field("idle_timeout", &self.idle_timeout) + .field("server_reasoning_included", &self.server_reasoning_included) + .field("models_etag", &self.models_etag) + .field("server_model", &self.server_model) + .field("telemetry", &self.telemetry.as_ref().map(|_| "")) + .finish() + } +} + impl ResponsesWebsocketConnection { fn new( stream: WsStream, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 658e212fa7..6f33910ce7 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -28,6 +28,7 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -123,6 +124,7 @@ struct ModelClientState { include_timing_metrics: bool, beta_features_header: Option, disable_websockets: AtomicBool, + cached_websocket_connection: StdMutex>, } /// Resolved API client setup for a single request attempt. @@ -228,6 +230,7 @@ impl ModelClient { include_timing_metrics, beta_features_header, disable_websockets: AtomicBool::new(false), + cached_websocket_connection: StdMutex::new(None), }), } } @@ -239,13 +242,29 @@ impl ModelClient { pub fn new_session(&self) -> ModelClientSession { ModelClientSession { client: self.clone(), - connection: None, + connection: self.take_cached_websocket_connection(), websocket_last_request: None, websocket_last_response_rx: None, turn_state: Arc::new(OnceLock::new()), } } + fn take_cached_websocket_connection(&self) -> Option { + self.state + .cached_websocket_connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + fn store_cached_websocket_connection(&self, connection: ApiWebSocketConnection) { + *self + .state + .cached_websocket_connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(connection); + } + /// Compacts the current conversation history using the Compact endpoint. /// /// This is a unary call (no streaming) that returns a new list of @@ -452,6 +471,14 @@ impl ModelClient { } } +impl Drop for ModelClientSession { + fn drop(&mut self) { + if let Some(connection) = self.connection.take() { + self.client.store_cached_websocket_connection(connection); + } + } +} + impl ModelClientSession { fn activate_http_fallback(&self, websocket_enabled: bool) -> bool { websocket_enabled diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1c7c578cae..736e5f50fe 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -118,6 +118,34 @@ async fn responses_websocket_preconnect_reuses_connection() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_reuses_connection_after_session_drop() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let prompt_one = prompt_with_input(vec![message_item("hello")]); + let prompt_two = prompt_with_input(vec![message_item("again")]); + + { + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_one).await; + } + + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_two).await; + + assert_eq!(server.handshakes().len(), 1); + assert_eq!(server.single_connection().len(), 2); + + 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!(); From 41f15bf07bbc77a4f84b30f50ae1c7b28eb173cd Mon Sep 17 00:00:00 2001 From: Max Johnson <162359438+maxj-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:10:51 -0800 Subject: [PATCH 18/39] app-server: add JSON tracing logs (#12287) - add `LOG_FORMAT=json` support for app-server tracing logs via `tracing_subscriber`'s built-in JSON formatter - keep the default human-readable format unchanged and keep `RUST_LOG` filtering behavior - document the env var and update lockfile --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 13 +++++++ codex-rs/app-server/Cargo.toml | 2 +- codex-rs/app-server/README.md | 5 +++ codex-rs/app-server/src/lib.rs | 70 ++++++++++++++++++++++++++++++---- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 03ad23ff77..008baf9393 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1332,6 +1332,7 @@ "tracing-error_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"registry\",\"fmt\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"}],\"features\":{\"default\":[\"traced-error\"],\"traced-error\":[]}}", "tracing-log_0.2.0": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"lru\",\"optional\":true,\"req\":\"^0.7.7\"},{\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"}],\"features\":{\"default\":[\"log-tracer\",\"std\"],\"interest-cache\":[\"lru\",\"ahash\"],\"log-tracer\":[],\"std\":[\"log/std\"]}}", "tracing-opentelemetry_0.32.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"js-sys\",\"req\":\"^0.3.64\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"trace\"],\"name\":\"opentelemetry\",\"req\":\"^0.31.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry\",\"req\":\"^0.31.0\"},{\"features\":[\"metrics\",\"grpc-tonic\"],\"kind\":\"dev\",\"name\":\"opentelemetry-otlp\",\"req\":\"^0.31.0\"},{\"features\":[\"semconv_experimental\"],\"kind\":\"dev\",\"name\":\"opentelemetry-semantic-conventions\",\"req\":\"^0.31.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry-stdout\",\"req\":\"^0.31.0\"},{\"default_features\":false,\"features\":[\"trace\",\"experimental_metrics_custom_reader\",\"testing\"],\"kind\":\"dev\",\"name\":\"opentelemetry_sdk\",\"req\":\"^0.31.0\"},{\"features\":[\"flamegraph\",\"criterion\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.15.0\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std\",\"attributes\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"kind\":\"dev\",\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"registry\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"registry\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"web-time\",\"req\":\"^1.0.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"}],\"features\":{\"default\":[\"tracing-log\",\"metrics\"],\"metrics\":[\"opentelemetry/metrics\",\"smallvec\"]}}", + "tracing-serde_0.2.0": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"valuable\":[\"valuable_crate\",\"valuable-serde\",\"tracing-core/valuable\"]}}", "tracing-subscriber_0.3.22": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"clock\",\"std\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.26\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"matchers\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"nu-ansi-term\",\"optional\":true,\"req\":\"^0.50.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.13.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.140\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.82\"},{\"name\":\"sharded-slab\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"thread_local\",\"optional\":true,\"req\":\"^1.1.4\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.2\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.43\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std-future\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"log-tracer\",\"std\"],\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-log\",\"req\":\"^0.2.0\"},{\"name\":\"tracing-serde\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"alloc\":[],\"ansi\":[\"fmt\",\"nu-ansi-term\"],\"default\":[\"smallvec\",\"fmt\",\"ansi\",\"tracing-log\",\"std\"],\"env-filter\":[\"matchers\",\"once_cell\",\"tracing\",\"std\",\"thread_local\",\"dep:regex-automata\"],\"fmt\":[\"registry\",\"std\"],\"json\":[\"tracing-serde\",\"serde\",\"serde_json\"],\"local-time\":[\"time/local-offset\"],\"nu-ansi-term\":[\"dep:nu-ansi-term\"],\"regex\":[],\"registry\":[\"sharded-slab\",\"thread_local\",\"std\"],\"std\":[\"alloc\",\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\",\"valuable_crate\",\"valuable-serde\",\"tracing-serde/valuable\"]}}", "tracing-test-macro_0.2.5": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"no-env-filter\":[]}}", "tracing-test_0.2.5": "{\"dependencies\":[{\"features\":[\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"tracing-test-macro\",\"req\":\"^0.2.5\"}],\"features\":{\"no-env-filter\":[\"tracing-test-macro/no-env-filter\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8d7edc4678..87f0e4cd41 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -9530,6 +9530,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.22" @@ -9540,12 +9550,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 84ace4e38d..302a65d2f6 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -50,7 +50,7 @@ tokio = { workspace = true, features = [ ] } tokio-tungstenite = { workspace = true } tracing = { workspace = true, features = ["log"] } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3d3cc18a9f..3246bebd8c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -28,6 +28,11 @@ Supported transports: Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. +Tracing/log output: + +- `RUST_LOG` controls log filtering/verbosity. +- Set `LOG_FORMAT=json` to emit app-server tracing logs to `stderr` as JSON (one event per line). + Backpressure behavior: - The server uses bounded queues between transport ingress, request processing, and outbound writes. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8e7e1455c4..6ff802c2a9 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -49,6 +49,7 @@ use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; mod bespoke_event_handling; @@ -67,6 +68,16 @@ mod transport; pub use crate::transport::AppServerTransport; +const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LogFormat { + Default, + Json, +} + +type StderrLogLayer = Box + Send + Sync + 'static>; + /// Control-plane messages from the processor/transport side to the outbound router task. /// /// `run_main_with_transport` now uses two loops/tasks: @@ -198,6 +209,20 @@ fn project_config_warning(config: &Config) -> Option }) } +impl LogFormat { + fn from_env_value(value: Option<&str>) -> Self { + match value.map(str::trim).map(str::to_ascii_lowercase) { + Some(value) if value == "json" => Self::Json, + _ => Self::Default, + } + } +} + +fn log_format_from_env() -> LogFormat { + let value = std::env::var(LOG_FORMAT_ENV_VAR).ok(); + LogFormat::from_env_value(value.as_deref()) +} + pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, @@ -342,18 +367,26 @@ pub async fn run_main_with_transport( ) })?; - // Install a simple subscriber so `tracing` output is visible. Users can - // control the log level with `RUST_LOG`. - let stderr_fmt = tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) - .with_filter(EnvFilter::from_default_env()); + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG` and switch to JSON logs with + // `LOG_FORMAT=json`. + let stderr_fmt: StderrLogLayer = match log_format_from_env() { + LogFormat::Json => tracing_subscriber::fmt::layer() + .json() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), + LogFormat::Default => tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL) + .with_filter(EnvFilter::from_default_env()) + .boxed(), + }; let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); - let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let _ = tracing_subscriber::registry() @@ -594,3 +627,24 @@ pub async fn run_main_with_transport( Ok(()) } + +#[cfg(test)] +mod tests { + use super::LogFormat; + use pretty_assertions::assert_eq; + + #[test] + fn log_format_from_env_value_matches_json_values_case_insensitively() { + assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json); + assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json); + } + + #[test] + fn log_format_from_env_value_defaults_for_non_json_values() { + assert_eq!(LogFormat::from_env_value(None), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); + assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); + } +} From e8afaed5029fdc16cee53c2007930b1d34401b79 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 10:39:55 -0800 Subject: [PATCH 19/39] Refactor network approvals to host/protocol/port scope (#12140) ## Summary Simplify network approvals by removing per-attempt proxy correlation and moving to session-level approval dedupe keyed by (host, protocol, port). Instead of encoding attempt IDs into proxy credentials/URLs, we now treat approvals as a destination policy decision. - Concurrent calls to the same destination share one approval prompt. - Different destinations (or same host on different ports) get separate prompts. - Allow once approves the current queued request group only. - Allow for session caches that (host, protocol, port) and auto-allows future matching requests. - Never policy continues to deny without prompting. Example: - 3 calls: - a.com (line 443) - b.com (line 443) - a.com (line 443) => 2 prompts total (a, b), second a waits on the first decision. - a.com:80 is treated separately from a.com line 443 ## Testing - `just fmt` (in `codex-rs`) - `cargo test -p codex-core tools::network_approval::tests` - `cargo test -p codex-core` (unit tests pass; existing integration-suite failures remain in this environment) --- codex-rs/Cargo.lock | 1 - ...CommandExecutionRequestApprovalParams.json | 35 + .../schema/json/ServerRequest.json | 35 + .../codex_app_server_protocol.schemas.json | 11 + .../CommandExecutionRequestApprovalParams.ts | 5 + .../typescript/v2/NetworkApprovalContext.ts | 6 + .../typescript/v2/NetworkApprovalProtocol.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../app-server-protocol/src/protocol/v2.rs | 32 + codex-rs/app-server-test-client/src/lib.rs | 4 + codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 62 +- .../app-server/src/codex_message_processor.rs | 1 - codex-rs/core/src/codex.rs | 2 - codex-rs/core/src/exec.rs | 24 +- codex-rs/core/src/network_policy_decision.rs | 2 - codex-rs/core/src/sandboxing/mod.rs | 2 - codex-rs/core/src/tasks/user_shell.rs | 1 - .../core/src/tools/handlers/apply_patch.rs | 2 - codex-rs/core/src/tools/handlers/shell.rs | 3 - codex-rs/core/src/tools/network_approval.rs | 761 ++++++++---------- codex-rs/core/src/tools/orchestrator.rs | 3 - codex-rs/core/src/tools/runtimes/shell.rs | 5 +- .../core/src/tools/runtimes/unified_exec.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 1 - .../core/src/unified_exec/async_watcher.rs | 37 - codex-rs/core/src/unified_exec/mod.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 64 +- codex-rs/core/tests/suite/exec.rs | 1 - .../exec-server/src/posix/escalate_server.rs | 1 - .../linux-sandbox/tests/suite/landlock.rs | 2 - codex-rs/network-proxy/Cargo.toml | 1 - codex-rs/network-proxy/src/http_proxy.rs | 50 -- codex-rs/network-proxy/src/lib.rs | 1 - codex-rs/network-proxy/src/metadata.rs | 50 -- codex-rs/network-proxy/src/network_policy.rs | 7 - codex-rs/network-proxy/src/proxy.rs | 36 +- codex-rs/network-proxy/src/runtime.rs | 14 +- codex-rs/network-proxy/src/socks5.rs | 8 - .../tui/src/bottom_pane/approval_overlay.rs | 24 +- 40 files changed, 570 insertions(+), 739 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts delete mode 100644 codex-rs/network-proxy/src/metadata.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 87f0e4cd41..ccdfddb3ac 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1986,7 +1986,6 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", "clap", "codex-utils-absolute-path", "codex-utils-rustls-provider", diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 1938701952..b7ad3fe315 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -110,6 +110,30 @@ "type": "object" } ] + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" } }, "properties": { @@ -147,6 +171,17 @@ "itemId": { "type": "string" }, + "networkApprovalContext": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional context for managed-network approval prompts." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 6be3bebc5e..865912058e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -213,6 +213,17 @@ "itemId": { "type": "string" }, + "networkApprovalContext": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional context for managed-network approval prompts." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { @@ -419,6 +430,30 @@ ], "type": "object" }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, "ParsedCommand": { "oneOf": [ { 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 df73bf2c46..e1e0137b7f 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 @@ -2192,6 +2192,17 @@ "itemId": { "type": "string" }, + "networkApprovalContext": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional context for managed-network approval prompts." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 290ce5669c..4021e50a6a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandAction } from "./CommandAction"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; +import type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type CommandExecutionRequestApprovalParams = { threadId: string, turnId: string, itemId: string, /** @@ -19,6 +20,10 @@ approvalId?: string | null, * Optional explanatory reason (e.g. request for network access). */ reason?: string | null, +/** + * Optional context for managed-network approval prompts. + */ +networkApprovalContext?: NetworkApprovalContext | null, /** * The command to be executed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.ts new file mode 100644 index 0000000000..b4b78e473c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalContext.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 { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; + +export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.ts new file mode 100644 index 0000000000..9dd4066fd1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkApprovalProtocol.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 NetworkApprovalProtocol = "http" | "https" | "socks5Tcp" | "socks5Udp"; 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 e595d0ce0b..db94051fa6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -100,6 +100,8 @@ export type { ModelListResponse } from "./ModelListResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { NetworkAccess } from "./NetworkAccess"; +export type { NetworkApprovalContext } from "./NetworkApprovalContext"; +export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkRequirements } from "./NetworkRequirements"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fb2f86931e..dfb185b40a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5,6 +5,8 @@ use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ForcedLoginMethod; @@ -650,6 +652,32 @@ pub enum CommandExecutionApprovalDecision { Cancel, } +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3331,6 +3359,10 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub reason: Option, + /// Optional context for managed-network approval prompts. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, /// The command to be executed. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 7b846c80f1..a1d10be334 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1491,6 +1491,7 @@ impl CodexClient { item_id, approval_id, reason, + network_approval_context, command, cwd, command_actions, @@ -1506,6 +1507,9 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } + if let Some(network_approval_context) = network_approval_context.as_ref() { + println!("< network approval context: {network_approval_context:?}"); + } if let Some(command) = command.as_deref() { println!("< command: {command}"); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3246bebd8c..95ab4e43eb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -667,7 +667,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), `reason`, plus `command`, `cwd`, and `commandActions` for friendly display. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. 3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`. 4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ea4fa691d5..fca2bcc1c1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -44,6 +44,7 @@ use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; @@ -106,6 +107,17 @@ use tracing::error; type JsonValue = serde_json::Value; +enum CommandExecutionApprovalPresentation { + Network(V2NetworkApprovalContext), + Command(CommandExecutionCompletionItem), +} + +struct CommandExecutionCompletionItem { + command: String, + cwd: PathBuf, + command_actions: Vec, +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, @@ -245,6 +257,7 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, + network_approval_context, proposed_execpolicy_amendment, parsed_cmd, .. @@ -280,7 +293,32 @@ pub(crate) async fn apply_bespoke_event_handling( .cloned() .map(V2ParsedCommand::from) .collect::>(); - let command_string = shlex_join(&command); + let presentation = if let Some(network_approval_context) = + network_approval_context.map(V2NetworkApprovalContext::from) + { + CommandExecutionApprovalPresentation::Network(network_approval_context) + } else { + let command_string = shlex_join(&command); + let completion_item = CommandExecutionCompletionItem { + command: command_string, + cwd: cwd.clone(), + command_actions: command_actions.clone(), + }; + CommandExecutionApprovalPresentation::Command(completion_item) + }; + let (network_approval_context, command, cwd, command_actions, completion_item) = + match presentation { + CommandExecutionApprovalPresentation::Network( + network_approval_context, + ) => (Some(network_approval_context), None, None, None, None), + CommandExecutionApprovalPresentation::Command(completion_item) => ( + None, + Some(completion_item.command.clone()), + Some(completion_item.cwd.clone()), + Some(completion_item.command_actions.clone()), + Some(completion_item), + ), + }; let proposed_execpolicy_amendment_v2 = proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); @@ -290,9 +328,10 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: call_id.clone(), approval_id: approval_id.clone(), reason, - command: Some(command_string.clone()), - cwd: Some(cwd.clone()), - command_actions: Some(command_actions.clone()), + network_approval_context, + command, + cwd, + command_actions, proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing @@ -306,9 +345,7 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, approval_id, call_id, - command_string, - cwd, - command_actions, + completion_item, rx, conversation, outgoing, @@ -1790,9 +1827,7 @@ async fn on_command_execution_request_approval_response( conversation_id: ThreadId, approval_id: Option, item_id: String, - command: String, - cwd: PathBuf, - command_actions: Vec, + completion_item: Option, receiver: oneshot::Receiver, conversation: Arc, outgoing: ThreadScopedOutgoingMessageSender, @@ -1864,15 +1899,16 @@ async fn on_command_execution_request_approval_response( if let Some(status) = completion_status && !suppress_subcommand_completion_item + && let Some(completion_item) = completion_item { complete_command_execution_item( conversation_id, event_turn_id.clone(), item_id.clone(), - command.clone(), - cwd.clone(), + completion_item.command, + completion_item.cwd, None, - command_actions.clone(), + completion_item.command_actions, status, &outgoing, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 60c9c4638a..24c89edaf3 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1763,7 +1763,6 @@ impl CodexMessageProcessor { network: started_network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy), - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, justification: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a384ffc00f..b5614cced5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8347,7 +8347,6 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, - network_attempt_id: None, sandbox_permissions, windows_sandbox_level: turn_context.windows_sandbox_level, justification: Some("test".to_string()), @@ -8361,7 +8360,6 @@ mod tests { expiration: timeout_ms.into(), env: HashMap::new(), network: None, - network_attempt_id: None, windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4e1220b87b..1e0e59d4e0 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -15,7 +15,6 @@ use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; use tokio_util::sync::CancellationToken; -use uuid::Uuid; use crate::error::CodexErr; use crate::error::Result; @@ -67,7 +66,6 @@ pub struct ExecParams { pub expiration: ExecExpiration, pub env: HashMap, pub network: Option, - pub network_attempt_id: Option, pub sandbox_permissions: SandboxPermissions, pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub justification: Option, @@ -186,15 +184,13 @@ pub async fn process_exec_tool_call( mut env, expiration, network, - network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, arg0: _, } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let (program, args) = command.split_first().ok_or_else(|| { CodexErr::Io(io::Error::new( @@ -242,7 +238,6 @@ pub(crate) async fn execute_exec_env( cwd, env, network, - network_attempt_id, expiration, sandbox, windows_sandbox_level, @@ -251,18 +246,12 @@ pub(crate) async fn execute_exec_env( arg0, } = env; - let network_attempt_id = match network_attempt_id.as_deref() { - Some(attempt_id) => Uuid::parse_str(attempt_id).ok(), - None => network.as_ref().map(|_| Uuid::new_v4()), - }; - let params = ExecParams { command, cwd, expiration, env, network: network.clone(), - network_attempt_id, sandbox_permissions, windows_sandbox_level, justification, @@ -356,14 +345,12 @@ async fn exec_windows_sandbox( cwd, mut env, network, - network_attempt_id, expiration, windows_sandbox_level, .. } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } // TODO(iceweasel-oai): run_windows_sandbox_capture should support all @@ -717,16 +704,13 @@ async fn exec( cwd, mut env, network, - network_attempt_id, arg0, expiration, windows_sandbox_level: _, .. } = params; - let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string()); - if let Some(network) = network.as_ref() { - network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let (program, args) = command.split_first().ok_or_else(|| { @@ -1137,7 +1121,6 @@ mod tests { expiration: 500.into(), env, network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, @@ -1191,7 +1174,6 @@ mod tests { expiration: ExecExpiration::Cancellation(cancel_token), env, network: None, - network_attempt_id: None, sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, diff --git a/codex-rs/core/src/network_policy_decision.rs b/codex-rs/core/src/network_policy_decision.rs index 3da6d34d6c..81ba7db82d 100644 --- a/codex-rs/core/src/network_policy_decision.rs +++ b/codex-rs/core/src/network_policy_decision.rs @@ -220,7 +220,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -238,7 +237,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("deny".to_string()), source: Some("baseline_policy".to_string()), port: Some(80), diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 4bf8f66092..f9c3c171e7 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -46,7 +46,6 @@ pub struct ExecRequest { pub cwd: PathBuf, pub env: HashMap, pub network: Option, - pub network_attempt_id: Option, pub expiration: ExecExpiration, pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, @@ -222,7 +221,6 @@ impl SandboxManager { cwd: spec.cwd, env, network: network.cloned(), - network_attempt_id: None, expiration: spec.expiration, sandbox, windows_sandbox_level, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 80112c61c7..d356193f58 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -151,7 +151,6 @@ pub(crate) async fn execute_user_shell_command( Some(session.conversation_id), ), network: turn_context.network.clone(), - network_attempt_id: None, // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 49c679e520..8f14fccedf 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -143,7 +143,6 @@ impl ToolHandler for ApplyPatchHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name: tool_name.to_string(), - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) @@ -234,7 +233,6 @@ pub(crate) async fn intercept_apply_patch( turn, call_id: call_id.to_string(), tool_name: tool_name.to_string(), - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, turn, turn.approval_policy) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 31bf3f1294..8ca6de0d80 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -54,7 +54,6 @@ impl ShellHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), - network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -84,7 +83,6 @@ impl ShellCommandHandler { expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), network: turn_context.network.clone(), - network_attempt_id: None, sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), @@ -327,7 +325,6 @@ impl ShellHandler { turn: turn.as_ref(), call_id: call_id.clone(), tool_name, - network_attempt_id: None, }; let out = orchestrator .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy) diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d5643e4feb..4a893f74e9 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -10,12 +10,14 @@ use codex_network_proxy::NetworkProtocol; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use indexmap::IndexMap; use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; +use tokio::sync::Notify; use tokio::sync::RwLock; use uuid::Uuid; @@ -27,168 +29,207 @@ pub(crate) enum NetworkApprovalMode { #[derive(Clone, Debug)] pub(crate) struct NetworkApprovalSpec { - pub command: Vec, - pub cwd: PathBuf, pub network: Option, pub mode: NetworkApprovalMode, } #[derive(Clone, Debug)] pub(crate) struct DeferredNetworkApproval { - attempt_id: String, + registration_id: String, } impl DeferredNetworkApproval { - pub(crate) fn attempt_id(&self) -> &str { - &self.attempt_id + pub(crate) fn registration_id(&self) -> &str { + &self.registration_id } } #[derive(Debug)] pub(crate) struct ActiveNetworkApproval { - attempt_id: Option, + registration_id: Option, mode: NetworkApprovalMode, } impl ActiveNetworkApproval { - pub(crate) fn attempt_id(&self) -> Option<&str> { - self.attempt_id.as_deref() - } - pub(crate) fn mode(&self) -> NetworkApprovalMode { self.mode } pub(crate) fn into_deferred(self) -> Option { - match (self.mode, self.attempt_id) { - (NetworkApprovalMode::Deferred, Some(attempt_id)) => { - Some(DeferredNetworkApproval { attempt_id }) + match (self.mode, self.registration_id) { + (NetworkApprovalMode::Deferred, Some(registration_id)) => { + Some(DeferredNetworkApproval { registration_id }) } _ => None, } } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct HostApprovalKey { + host: String, + protocol: &'static str, + port: u16, +} + +impl HostApprovalKey { + fn from_request(request: &NetworkPolicyRequest, protocol: NetworkApprovalProtocol) -> Self { + Self { + host: request.host.to_ascii_lowercase(), + protocol: protocol_key_label(protocol), + port: request.port, + } + } +} + +fn protocol_key_label(protocol: NetworkApprovalProtocol) -> &'static str { + match protocol { + NetworkApprovalProtocol::Http => "http", + NetworkApprovalProtocol::Https => "https", + NetworkApprovalProtocol::Socks5Tcp => "socks5-tcp", + NetworkApprovalProtocol::Socks5Udp => "socks5-udp", + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PendingApprovalDecision { + AllowOnce, + AllowForSession, + Deny, +} + #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum NetworkApprovalOutcome { +enum NetworkApprovalOutcome { DeniedByUser, DeniedByPolicy(String), } -struct NetworkApprovalAttempt { - turn_id: String, - call_id: String, - command: Vec, - cwd: PathBuf, - approved_hosts: Mutex>, - outcome: Mutex>, +fn allows_network_prompt(policy: AskForApproval) -> bool { + !matches!(policy, AskForApproval::Never) +} + +impl PendingApprovalDecision { + fn to_network_decision(self) -> NetworkDecision { + match self { + Self::AllowOnce | Self::AllowForSession => NetworkDecision::Allow, + Self::Deny => NetworkDecision::deny("not_allowed"), + } + } +} + +struct PendingHostApproval { + decision: Mutex>, + notify: Notify, +} + +impl PendingHostApproval { + fn new() -> Self { + Self { + decision: Mutex::new(None), + notify: Notify::new(), + } + } + + async fn wait_for_decision(&self) -> PendingApprovalDecision { + loop { + let notified = self.notify.notified(); + if let Some(decision) = *self.decision.lock().await { + return decision; + } + notified.await; + } + } + + async fn set_decision(&self, decision: PendingApprovalDecision) { + { + let mut current = self.decision.lock().await; + *current = Some(decision); + } + self.notify.notify_waiters(); + } +} + +struct ActiveNetworkApprovalCall { + registration_id: String, } pub(crate) struct NetworkApprovalService { - attempts: Mutex>>, - session_approved_hosts: Mutex>, + active_calls: Mutex>>, + call_outcomes: Mutex>, + pending_host_approvals: Mutex>>, + session_approved_hosts: Mutex>, } impl Default for NetworkApprovalService { fn default() -> Self { Self { - attempts: Mutex::new(HashMap::new()), + active_calls: Mutex::new(IndexMap::new()), + call_outcomes: Mutex::new(HashMap::new()), + pending_host_approvals: Mutex::new(HashMap::new()), session_approved_hosts: Mutex::new(HashSet::new()), } } } impl NetworkApprovalService { - pub(crate) async fn register_attempt( - &self, - attempt_id: String, - turn_id: String, - call_id: String, - command: Vec, - cwd: PathBuf, - ) { - let mut attempts = self.attempts.lock().await; - attempts.insert( - attempt_id, - Arc::new(NetworkApprovalAttempt { - turn_id, - call_id, - command, - cwd, - approved_hosts: Mutex::new(HashSet::new()), - outcome: Mutex::new(None), - }), - ); + async fn register_call(&self, registration_id: String) { + let mut active_calls = self.active_calls.lock().await; + let key = registration_id.clone(); + active_calls.insert(key, Arc::new(ActiveNetworkApprovalCall { registration_id })); } - pub(crate) async fn unregister_attempt(&self, attempt_id: &str) { - let mut attempts = self.attempts.lock().await; - attempts.remove(attempt_id); + pub(crate) async fn unregister_call(&self, registration_id: &str) { + let mut active_calls = self.active_calls.lock().await; + active_calls.shift_remove(registration_id); + let mut call_outcomes = self.call_outcomes.lock().await; + call_outcomes.remove(registration_id); } - pub(crate) async fn take_outcome(&self, attempt_id: &str) -> Option { - let attempt = { - let attempts = self.attempts.lock().await; - attempts.get(attempt_id).cloned() - }?; - let mut outcome = attempt.outcome.lock().await; - outcome.take() - } - - pub(crate) async fn take_user_denial_outcome(&self, attempt_id: &str) -> bool { - let attempt = { - let attempts = self.attempts.lock().await; - attempts.get(attempt_id).cloned() - }; - let Some(attempt) = attempt else { - return false; - }; - let mut outcome = attempt.outcome.lock().await; - if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { - outcome.take(); - return true; - } - false - } - - async fn resolve_attempt_for_request( - &self, - request: &NetworkPolicyRequest, - ) -> Option> { - let attempts = self.attempts.lock().await; - - if let Some(attempt_id) = request.attempt_id.as_deref() { - if let Some(attempt) = attempts.get(attempt_id).cloned() { - return Some(attempt); - } - return None; - } - - if attempts.len() == 1 { - return attempts.values().next().cloned(); + async fn resolve_single_active_call(&self) -> Option> { + let active_calls = self.active_calls.lock().await; + if active_calls.len() == 1 { + return active_calls.values().next().cloned(); } None } - async fn resolve_attempt_for_blocked_request( + async fn get_or_create_pending_approval( &self, - blocked: &BlockedRequest, - ) -> Option> { - let attempts = self.attempts.lock().await; - - if let Some(attempt_id) = blocked.attempt_id.as_deref() { - if let Some(attempt) = attempts.get(attempt_id).cloned() { - return Some(attempt); - } - return None; + key: HostApprovalKey, + ) -> (Arc, bool) { + let mut pending = self.pending_host_approvals.lock().await; + if let Some(existing) = pending.get(&key).cloned() { + return (existing, false); } - if attempts.len() == 1 { - return attempts.values().next().cloned(); - } + let created = Arc::new(PendingHostApproval::new()); + pending.insert(key, Arc::clone(&created)); + (created, true) + } - None + async fn record_outcome_for_single_active_call(&self, outcome: NetworkApprovalOutcome) { + let Some(owner_call) = self.resolve_single_active_call().await else { + return; + }; + self.record_call_outcome(&owner_call.registration_id, outcome) + .await; + } + + async fn take_call_outcome(&self, registration_id: &str) -> Option { + let mut call_outcomes = self.call_outcomes.lock().await; + call_outcomes.remove(registration_id) + } + + async fn record_call_outcome(&self, registration_id: &str, outcome: NetworkApprovalOutcome) { + let mut call_outcomes = self.call_outcomes.lock().await; + if matches!( + call_outcomes.get(registration_id), + Some(NetworkApprovalOutcome::DeniedByUser) + ) { + return; + } + call_outcomes.insert(registration_id.to_string(), outcome); } pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) { @@ -196,15 +237,24 @@ impl NetworkApprovalService { return; }; - let Some(attempt) = self.resolve_attempt_for_blocked_request(&blocked).await else { - return; - }; + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message)) + .await; + } - let mut outcome = attempt.outcome.lock().await; - if matches!(outcome.as_ref(), Some(NetworkApprovalOutcome::DeniedByUser)) { - return; - } - *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy(message)); + async fn active_turn_context(session: &Session) -> Option> { + let active_turn = session.active_turn.lock().await; + active_turn + .as_ref() + .and_then(|turn| turn.tasks.first()) + .map(|(_, task)| Arc::clone(&task.turn_context)) + } + + fn format_network_target(protocol: &str, host: &str, port: u16) -> String { + format!("{protocol}://{host}:{port}") + } + + fn approval_id_for_key(key: &HostApprovalKey) -> String { + format!("network#{}#{}#{}", key.protocol, key.host, key.port) } pub(crate) async fn handle_inline_policy_request( @@ -214,46 +264,63 @@ impl NetworkApprovalService { ) -> NetworkDecision { const REASON_NOT_ALLOWED: &str = "not_allowed"; - { - let approved_hosts = self.session_approved_hosts.lock().await; - if approved_hosts.contains(request.host.as_str()) { - return NetworkDecision::Allow; - } - } - - let Some(attempt) = self.resolve_attempt_for_request(&request).await else { - return NetworkDecision::deny(REASON_NOT_ALLOWED); - }; - - { - let approved_hosts = attempt.approved_hosts.lock().await; - if approved_hosts.contains(request.host.as_str()) { - return NetworkDecision::Allow; - } - } - let protocol = match request.protocol { NetworkProtocol::Http => NetworkApprovalProtocol::Http, NetworkProtocol::HttpsConnect => NetworkApprovalProtocol::Https, NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; + let key = HostApprovalKey::from_request(&request, protocol); - let Some(turn_context) = session.turn_context_for_sub_id(&attempt.turn_id).await else { + { + let approved_hosts = self.session_approved_hosts.lock().await; + if approved_hosts.contains(&key) { + return NetworkDecision::Allow; + } + } + + let (pending, is_owner) = self.get_or_create_pending_approval(key.clone()).await; + if !is_owner { + return pending.wait_for_decision().await.to_network_decision(); + } + + let target = Self::format_network_target(key.protocol, request.host.as_str(), key.port); + let policy_denial_message = + format!("Network access to \"{target}\" was blocked by policy."); + let prompt_reason = format!("{} is not in the allowed_domains", request.host); + + let Some(turn_context) = Self::active_turn_context(session).await else { + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( + policy_denial_message, + )) + .await; return NetworkDecision::deny(REASON_NOT_ALLOWED); }; + if !allows_network_prompt(turn_context.approval_policy) { + pending.set_decision(PendingApprovalDecision::Deny).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy( + policy_denial_message, + )) + .await; + return NetworkDecision::deny(REASON_NOT_ALLOWED); + } + + let approval_id = Self::approval_id_for_key(&key); + let prompt_command = vec!["network-access".to_string(), target.clone()]; let approval_decision = session .request_command_approval( turn_context.as_ref(), - attempt.call_id.clone(), + approval_id, None, - attempt.command.clone(), - attempt.cwd.clone(), - Some(format!( - "Network access to \"{}\" is blocked by policy.", - request.host - )), + prompt_command, + turn_context.cwd.clone(), + Some(prompt_reason), Some(NetworkApprovalContext { host: request.host.clone(), protocol, @@ -262,23 +329,28 @@ impl NetworkApprovalService { ) .await; - match approval_decision { + let resolved = match approval_decision { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { - let mut approved_hosts = attempt.approved_hosts.lock().await; - approved_hosts.insert(request.host); - NetworkDecision::Allow - } - ReviewDecision::ApprovedForSession => { - let mut approved_hosts = self.session_approved_hosts.lock().await; - approved_hosts.insert(request.host); - NetworkDecision::Allow + PendingApprovalDecision::AllowOnce } + ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession, ReviewDecision::Denied | ReviewDecision::Abort => { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - NetworkDecision::deny(REASON_NOT_ALLOWED) + self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByUser) + .await; + PendingApprovalDecision::Deny } + }; + + if matches!(resolved, PendingApprovalDecision::AllowForSession) { + let mut approved_hosts = self.session_approved_hosts.lock().await; + approved_hosts.insert(key.clone()); } + + pending.set_decision(resolved).await; + let mut pending_approvals = self.pending_host_approvals.lock().await; + pending_approvals.remove(&key); + + resolved.to_network_decision() } } @@ -313,8 +385,8 @@ pub(crate) fn build_network_policy_decider( pub(crate) async fn begin_network_approval( session: &Session, - turn_id: &str, - call_id: &str, + _turn_id: &str, + _call_id: &str, has_managed_network_requirements: bool, spec: Option, ) -> Option { @@ -323,21 +395,15 @@ pub(crate) async fn begin_network_approval( return None; } - let attempt_id = Uuid::new_v4().to_string(); + let registration_id = Uuid::new_v4().to_string(); session .services .network_approval - .register_attempt( - attempt_id.clone(), - turn_id.to_string(), - call_id.to_string(), - spec.command, - spec.cwd, - ) + .register_call(registration_id.clone()) .await; Some(ActiveNetworkApproval { - attempt_id: Some(attempt_id), + registration_id: Some(registration_id), mode: spec.mode, }) } @@ -346,20 +412,20 @@ pub(crate) async fn finish_immediate_network_approval( session: &Session, active: ActiveNetworkApproval, ) -> Result<(), ToolError> { - let Some(attempt_id) = active.attempt_id.as_deref() else { + let Some(registration_id) = active.registration_id.as_deref() else { return Ok(()); }; let approval_outcome = session .services .network_approval - .take_outcome(attempt_id) + .take_call_outcome(registration_id) .await; session .services .network_approval - .unregister_attempt(attempt_id) + .unregister_call(registration_id) .await; match approval_outcome { @@ -371,22 +437,6 @@ pub(crate) async fn finish_immediate_network_approval( } } -pub(crate) async fn deferred_rejection_message( - session: &Session, - deferred: &DeferredNetworkApproval, -) -> Option { - match session - .services - .network_approval - .take_outcome(deferred.attempt_id()) - .await - { - Some(NetworkApprovalOutcome::DeniedByUser) => Some("rejected by user".to_string()), - Some(NetworkApprovalOutcome::DeniedByPolicy(message)) => Some(message), - None => None, - } -} - pub(crate) async fn finish_deferred_network_approval( session: &Session, deferred: Option, @@ -397,7 +447,7 @@ pub(crate) async fn finish_deferred_network_approval( session .services .network_approval - .unregister_attempt(deferred.attempt_id()) + .unregister_call(deferred.registration_id()) .await; } @@ -405,272 +455,145 @@ pub(crate) async fn finish_deferred_network_approval( mod tests { use super::*; use codex_network_proxy::BlockedRequestArgs; - use codex_network_proxy::NetworkPolicyRequestArgs; + use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; - fn http_request(host: &str, attempt_id: Option<&str>) -> NetworkPolicyRequest { - NetworkPolicyRequest::new(NetworkPolicyRequestArgs { - protocol: NetworkProtocol::Http, + #[tokio::test] + async fn pending_approvals_are_deduped_per_host_protocol_and_port() { + let service = NetworkApprovalService::default(); + let key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "http", + port: 443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(key.clone()).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(key).await; + + assert!(first_is_owner); + assert!(!second_is_owner); + assert!(Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn pending_approvals_do_not_dedupe_across_ports() { + let service = NetworkApprovalService::default(); + let first_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 443, + }; + let second_key = HostApprovalKey { + host: "example.com".to_string(), + protocol: "https", + port: 8443, + }; + + let (first, first_is_owner) = service.get_or_create_pending_approval(first_key).await; + let (second, second_is_owner) = service.get_or_create_pending_approval(second_key).await; + + assert!(first_is_owner); + assert!(second_is_owner); + assert!(!Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn pending_waiters_receive_owner_decision() { + let pending = Arc::new(PendingHostApproval::new()); + + let waiter = { + let pending = Arc::clone(&pending); + tokio::spawn(async move { pending.wait_for_decision().await }) + }; + + pending + .set_decision(PendingApprovalDecision::AllowOnce) + .await; + + let decision = waiter.await.expect("waiter should complete"); + assert_eq!(decision, PendingApprovalDecision::AllowOnce); + } + + #[test] + fn allow_once_and_allow_for_session_both_allow_network() { + assert_eq!( + PendingApprovalDecision::AllowOnce.to_network_decision(), + NetworkDecision::Allow + ); + assert_eq!( + PendingApprovalDecision::AllowForSession.to_network_decision(), + NetworkDecision::Allow + ); + } + + #[test] + fn never_policy_disables_network_prompts() { + assert!(!allows_network_prompt(AskForApproval::Never)); + assert!(allows_network_prompt(AskForApproval::OnRequest)); + assert!(allows_network_prompt(AskForApproval::OnFailure)); + assert!(allows_network_prompt(AskForApproval::UnlessTrusted)); + } + + fn denied_blocked_request(host: &str) -> BlockedRequest { + BlockedRequest::new(BlockedRequestArgs { host: host.to_string(), - port: 80, - client_addr: None, - method: Some("GET".to_string()), - command: None, - exec_policy_hint: None, - attempt_id: attempt_id.map(ToString::to_string), + reason: "not_allowed".to_string(), + client: None, + method: None, + mode: None, + protocol: "http".to_string(), + decision: Some("deny".to_string()), + source: Some("decider".to_string()), + port: Some(80), }) } #[tokio::test] - async fn resolve_attempt_for_request_falls_back_to_single_active_attempt() { + async fn record_blocked_request_sets_policy_outcome_for_owner_call() { let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) + .record_blocked_request(denied_blocked_request("example.com")) .await; - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", None)) - .await - .expect("single active attempt should be used as fallback"); - assert_eq!(resolved.call_id, "call-1"); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_exact_attempt_match() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - service - .register_attempt( - "attempt-2".to_string(), - "turn-2".to_string(), - "call-2".to_string(), - vec!["curl".to_string(), "openai.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("openai.com", Some("attempt-2"))) - .await - .expect("attempt-2 should resolve"); - assert_eq!(resolved.call_id, "call-2"); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_none_for_unknown_attempt_id() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", Some("attempt-unknown"))) - .await; - assert!(resolved.is_none()); - } - - #[tokio::test] - async fn resolve_attempt_for_request_returns_none_when_ambiguous() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - service - .register_attempt( - "attempt-2".to_string(), - "turn-2".to_string(), - "call-2".to_string(), - vec!["curl".to_string(), "robinhood.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let resolved = service - .resolve_attempt_for_request(&http_request("example.com", None)) - .await; - assert!(resolved.is_none()); - } - - #[tokio::test] - async fn take_outcome_clears_stored_value() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - } - assert_eq!( - service.take_outcome("attempt-1").await, - Some(NetworkApprovalOutcome::DeniedByUser) - ); - assert_eq!(service.take_outcome("attempt-1").await, None); - } - - #[tokio::test] - async fn take_user_denial_outcome_preserves_policy_denial() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByPolicy( - "policy denied".to_string(), - )); - } - - assert!(!service.take_user_denial_outcome("attempt-1").await); - assert_eq!( - service.take_outcome("attempt-1").await, + service.take_call_outcome("registration-1").await, Some(NetworkApprovalOutcome::DeniedByPolicy( - "policy denied".to_string(), + "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() )) ); } #[tokio::test] - async fn record_blocked_request_stores_policy_denial_outcome() { + async fn blocked_request_policy_does_not_override_user_denial_outcome() { let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) + .record_call_outcome("registration-1", NetworkApprovalOutcome::DeniedByUser) .await; - service - .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { - host: "example.com".to_string(), - reason: "denied".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("deny".to_string()), - source: Some("baseline_policy".to_string()), - port: Some(80), - })) - .await; - - let outcome = service - .take_outcome("attempt-1") - .await - .expect("outcome should be recorded"); - match outcome { - NetworkApprovalOutcome::DeniedByPolicy(message) => { - assert_eq!( - message, - "Network access to \"example.com\" was blocked: domain is explicitly denied by policy and cannot be approved from this prompt.".to_string() - ); - } - NetworkApprovalOutcome::DeniedByUser => panic!("expected policy denial"), - } - } - - #[tokio::test] - async fn record_blocked_request_does_not_override_user_denial() { - let service = NetworkApprovalService::default(); - service - .register_attempt( - "attempt-1".to_string(), - "turn-1".to_string(), - "call-1".to_string(), - vec!["curl".to_string(), "example.com".to_string()], - std::env::temp_dir(), - ) - .await; - - let attempt = { - let attempts = service.attempts.lock().await; - attempts - .get("attempt-1") - .cloned() - .expect("attempt should exist") - }; - { - let mut outcome = attempt.outcome.lock().await; - *outcome = Some(NetworkApprovalOutcome::DeniedByUser); - } - - service - .record_blocked_request(BlockedRequest::new(BlockedRequestArgs { - host: "example.com".to_string(), - reason: "denied".to_string(), - client: None, - method: Some("GET".to_string()), - mode: None, - protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), - decision: Some("deny".to_string()), - source: Some("baseline_policy".to_string()), - port: Some(80), - })) + .record_blocked_request(denied_blocked_request("example.com")) .await; assert_eq!( - service.take_outcome("attempt-1").await, + service.take_call_outcome("registration-1").await, Some(NetworkApprovalOutcome::DeniedByUser) ); } + + #[tokio::test] + async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests() { + let service = NetworkApprovalService::default(); + service.register_call("registration-1".to_string()).await; + service.register_call("registration-2".to_string()).await; + + service + .record_blocked_request(denied_blocked_request("example.com")) + .await; + + assert_eq!(service.take_call_outcome("registration-1").await, None); + assert_eq!(service.take_call_outcome("registration-2").await, None); + } } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index cdbaac0254..afb24d4b3c 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -69,9 +69,6 @@ impl ToolOrchestrator { turn: tool_ctx.turn, call_id: tool_ctx.call_id.clone(), tool_name: tool_ctx.tool_name.clone(), - network_attempt_id: network_approval.as_ref().and_then(|network_approval| { - network_approval.attempt_id().map(ToString::to_string) - }), }; let run_result = tool.run(req, attempt, &attempt_tool_ctx).await; diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6f1e4b44af..bcbeafadfe 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -154,8 +154,6 @@ impl ToolRuntime for ShellRuntime { ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { - command: req.command.clone(), - cwd: req.cwd.clone(), network: req.network.clone(), mode: NetworkApprovalMode::Immediate, }) @@ -221,10 +219,9 @@ impl ToolRuntime for ShellRuntime { req.sandbox_permissions, req.justification.clone(), )?; - let mut env = attempt + let env = attempt .env_for(spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; - env.network_attempt_id = ctx.network_attempt_id.clone(); let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index be049d0934..201de7ad84 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -157,8 +157,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { - command: req.command.clone(), - cwd: req.cwd.clone(), network: req.network.clone(), mode: NetworkApprovalMode::Deferred, }) @@ -188,7 +186,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt let mut env = req.env.clone(); if let Some(network) = req.network.as_ref() { - network.apply_to_env_for_attempt(&mut env, ctx.network_attempt_id.as_deref()); + network.apply_to_env(&mut env); } let spec = build_command_spec( &command, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 89b4bc01ec..9721fed535 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -272,7 +272,6 @@ pub(crate) struct ToolCtx<'a> { pub turn: &'a TurnContext, pub call_id: String, pub tool_name: String, - pub network_attempt_id: Option, } #[derive(Debug)] diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 772baab00b..1fbb1e8f6d 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -139,43 +139,6 @@ pub(crate) fn spawn_exit_watcher( }); } -pub(crate) fn spawn_network_denial_watcher( - process: Arc, - session: Arc, - process_id: String, - network_attempt_id: String, -) { - let exit_token = process.cancellation_token(); - tokio::spawn(async move { - let mut poll = tokio::time::interval(Duration::from_millis(100)); - poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - _ = exit_token.cancelled() => { - break; - } - _ = poll.tick() => { - if session - .services - .network_approval - .take_user_denial_outcome(&network_attempt_id) - .await - { - process.terminate(); - session - .services - .unified_exec_manager - .release_process_id(&process_id) - .await; - break; - } - } - } - } - }); -} - async fn process_chunk( pending: &mut Vec, transcript: &Arc>, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 4339d25ace..a586572dd1 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -155,7 +155,7 @@ struct ProcessEntry { process_id: String, command: Vec, tty: bool, - network_attempt_id: Option, + network_approval_id: Option, session: Weak, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index f2468583d2..6945316469 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -20,7 +20,6 @@ use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::events::ToolEventStage; use crate::tools::network_approval::DeferredNetworkApproval; -use crate::tools::network_approval::deferred_rejection_message; use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; @@ -44,7 +43,6 @@ use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec; use crate::unified_exec::async_watcher::spawn_exit_watcher; -use crate::unified_exec::async_watcher::spawn_network_denial_watcher; use crate::unified_exec::async_watcher::start_streaming_output; use crate::unified_exec::clamp_yield_time; use crate::unified_exec::generate_chunk_id; @@ -140,18 +138,18 @@ impl UnifiedExecProcessManager { store.remove(process_id) }; if let Some(entry) = removed { - Self::unregister_network_attempt_for_entry(&entry).await; + Self::unregister_network_approval_for_entry(&entry).await; } } - async fn unregister_network_attempt_for_entry(entry: &ProcessEntry) { - if let Some(attempt_id) = entry.network_attempt_id.as_deref() + async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { + if let Some(network_approval_id) = entry.network_approval_id.as_deref() && let Some(session) = entry.session.upgrade() { session .services .network_approval - .unregister_attempt(attempt_id) + .unregister_call(network_approval_id) .await; } } @@ -248,17 +246,6 @@ impl UnifiedExecProcessManager { .await; self.release_process_id(&request.process_id).await; - if let Some(deferred) = deferred_network_approval.as_ref() - && let Some(message) = - deferred_rejection_message(context.session.as_ref(), deferred).await - { - finish_deferred_network_approval( - context.session.as_ref(), - deferred_network_approval.take(), - ) - .await; - return Err(UnifiedExecError::create_process(message)); - } finish_deferred_network_approval( context.session.as_ref(), deferred_network_approval.take(), @@ -266,27 +253,13 @@ impl UnifiedExecProcessManager { .await; process.check_for_sandbox_denial_with_text(&text).await?; } else { - if let Some(deferred) = deferred_network_approval.as_ref() - && let Some(message) = - deferred_rejection_message(context.session.as_ref(), deferred).await - { - process.terminate(); - finish_deferred_network_approval( - context.session.as_ref(), - deferred_network_approval.take(), - ) - .await; - self.release_process_id(&request.process_id).await; - return Err(UnifiedExecError::create_process(message)); - } - // Long‑lived command: persist the process so write_stdin can reuse // it, and register a background watcher that will emit // ExecCommandEnd when the PTY eventually exits (even if no further // tool calls are made). - let network_attempt_id = deferred_network_approval + let network_approval_id = deferred_network_approval .as_ref() - .map(|deferred| deferred.attempt_id().to_string()); + .map(|deferred| deferred.registration_id().to_string()); self.store_process( Arc::clone(&process), context, @@ -295,7 +268,7 @@ impl UnifiedExecProcessManager { start, process_id, request.tty, - network_attempt_id, + network_approval_id, Arc::clone(&transcript), ) .await; @@ -443,7 +416,7 @@ impl UnifiedExecProcessManager { } }; if let ProcessStatus::Exited { entry, .. } = &status { - Self::unregister_network_attempt_for_entry(entry).await; + Self::unregister_network_approval_for_entry(entry).await; } status } @@ -502,17 +475,16 @@ impl UnifiedExecProcessManager { started_at: Instant, process_id: String, tty: bool, - network_attempt_id: Option, + network_approval_id: Option, transcript: Arc>, ) { - let network_attempt_id_for_watcher = network_attempt_id.clone(); let entry = ProcessEntry { process: Arc::clone(&process), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), tty, - network_attempt_id, + network_approval_id, session: Arc::downgrade(&context.session), last_used: started_at, }; @@ -525,7 +497,7 @@ impl UnifiedExecProcessManager { // prune_processes_if_needed runs while holding process_store; do async // network-approval cleanup only after dropping that lock. if let Some(pruned_entry) = pruned_entry { - Self::unregister_network_attempt_for_entry(&pruned_entry).await; + Self::unregister_network_approval_for_entry(&pruned_entry).await; pruned_entry.process.terminate(); } @@ -550,17 +522,6 @@ impl UnifiedExecProcessManager { transcript, started_at, ); - - if context.turn.config.managed_network_requirements_enabled() - && let Some(network_attempt_id) = network_attempt_id_for_watcher - { - spawn_network_denial_watcher( - Arc::clone(&process), - Arc::clone(&context.session), - process_id, - network_attempt_id, - ); - } } pub(crate) async fn open_session_with_exec_env( @@ -637,7 +598,6 @@ impl UnifiedExecProcessManager { turn: context.turn.as_ref(), call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), - network_attempt_id: None, }; orchestrator .run( @@ -792,7 +752,7 @@ impl UnifiedExecProcessManager { }; for entry in entries { - Self::unregister_network_attempt_for_entry(&entry).await; + Self::unregister_network_approval_for_entry(&entry).await; entry.process.terminate(); } } diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index c857affd2b..c3cb15875b 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -37,7 +37,6 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result(input: &T) -> Option { .map(|info| info.peer_addr().to_string()) } -fn request_network_attempt_id(req: &Request) -> Option { - // Some HTTP stacks normalize proxy credentials into `authorization`; accept both. - attempt_id_from_proxy_authorization(req.headers().get("proxy-authorization")) - .or_else(|| attempt_id_from_proxy_authorization(req.headers().get("authorization"))) -} - fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) { while let Some(raw_connection) = headers.get(header::CONNECTION).cloned() { headers.remove(header::CONNECTION); @@ -738,7 +721,6 @@ async fn proxy_disabled_response( method, mode: None, protocol: protocol.as_policy_protocol().to_string(), - attempt_id: None, decision: Some("deny".to_string()), source: Some("proxy_state".to_string()), port: Some(port), @@ -796,8 +778,6 @@ mod tests { use crate::config::NetworkMode; use crate::config::NetworkProxySettings; use crate::runtime::network_proxy_state_for_policy; - use base64::Engine; - use base64::engine::general_purpose::STANDARD; use pretty_assertions::assert_eq; use rama_http::Method; use rama_http::Request; @@ -873,36 +853,6 @@ mod tests { ); } - #[test] - fn request_network_attempt_id_reads_proxy_authorization_header() { - let encoded = STANDARD.encode("codex-net-attempt-attempt-1:"); - let req = Request::builder() - .method(Method::GET) - .uri("http://example.com") - .header("proxy-authorization", format!("Basic {encoded}")) - .body(Body::empty()) - .unwrap(); - assert_eq!( - request_network_attempt_id(&req), - Some("attempt-1".to_string()) - ); - } - - #[test] - fn request_network_attempt_id_reads_authorization_header_fallback() { - let encoded = STANDARD.encode("codex-net-attempt-attempt-2:"); - let req = Request::builder() - .method(Method::GET) - .uri("http://example.com") - .header("authorization", format!("Basic {encoded}")) - .body(Body::empty()) - .unwrap(); - assert_eq!( - request_network_attempt_id(&req), - Some("attempt-2".to_string()) - ); - } - #[test] fn remove_hop_by_hop_request_headers_keeps_forwarding_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 536dd4bf6f..f0cb3ae471 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -3,7 +3,6 @@ mod admin; mod config; mod http_proxy; -mod metadata; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/metadata.rs b/codex-rs/network-proxy/src/metadata.rs deleted file mode 100644 index 2542125c35..0000000000 --- a/codex-rs/network-proxy/src/metadata.rs +++ /dev/null @@ -1,50 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use rama_http::HeaderValue; - -pub const NETWORK_ATTEMPT_USERNAME_PREFIX: &str = "codex-net-attempt-"; - -pub fn proxy_username_for_attempt_id(attempt_id: &str) -> String { - format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}{attempt_id}") -} - -pub fn attempt_id_from_proxy_authorization(header: Option<&HeaderValue>) -> Option { - let header = header?; - let raw = header.to_str().ok()?; - let encoded = raw.strip_prefix("Basic ")?; - let decoded = STANDARD.decode(encoded.trim()).ok()?; - let decoded = String::from_utf8(decoded).ok()?; - let username = decoded - .split_once(':') - .map(|(user, _)| user) - .unwrap_or(decoded.as_str()); - let attempt_id = username.strip_prefix(NETWORK_ATTEMPT_USERNAME_PREFIX)?; - if attempt_id.is_empty() { - None - } else { - Some(attempt_id.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD; - - #[test] - fn parses_attempt_id_from_proxy_authorization_header() { - let encoded = STANDARD.encode(format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}abc123:")); - let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(); - assert_eq!( - attempt_id_from_proxy_authorization(Some(&header)), - Some("abc123".to_string()) - ); - } - - #[test] - fn ignores_non_attempt_proxy_authorization_header() { - let encoded = STANDARD.encode("normal-user:password"); - let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap(); - assert_eq!(attempt_id_from_proxy_authorization(Some(&header)), None); - } -} diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index fdbbe92819..6d8ec48d91 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -71,7 +71,6 @@ pub struct NetworkPolicyRequest { pub method: Option, pub command: Option, pub exec_policy_hint: Option, - pub attempt_id: Option, } pub struct NetworkPolicyRequestArgs { @@ -82,7 +81,6 @@ pub struct NetworkPolicyRequestArgs { pub method: Option, pub command: Option, pub exec_policy_hint: Option, - pub attempt_id: Option, } impl NetworkPolicyRequest { @@ -95,7 +93,6 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, - attempt_id, } = args; Self { protocol, @@ -105,7 +102,6 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, - attempt_id, } } } @@ -258,7 +254,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) @@ -292,7 +287,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) @@ -333,7 +327,6 @@ mod tests { method: Some("GET".to_string()), command: None, exec_policy_hint: None, - attempt_id: None, }); let decision = evaluate_host_policy(&state, Some(&decider), &request) diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 2e93fb7a1b..ba385dbba4 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1,7 +1,6 @@ use crate::admin; use crate::config; use crate::http_proxy; -use crate::metadata::proxy_username_for_attempt_id; use crate::network_policy::NetworkPolicyDecider; use crate::runtime::BlockedRequestObserver; use crate::runtime::unix_socket_permissions_supported; @@ -338,12 +337,8 @@ fn apply_proxy_env_overrides( socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, - network_attempt_id: Option<&str>, ) { - let http_proxy_url = network_attempt_id - .map(proxy_username_for_attempt_id) - .map(|username| format!("http://{username}@{http_addr}")) - .unwrap_or_else(|| format!("http://{http_addr}")); + let http_proxy_url = format!("http://{http_addr}"); let socks_proxy_url = format!("socks5h://{socks_addr}"); env.insert( ALLOW_LOCAL_BINDING_ENV_KEY.to_string(), @@ -390,9 +385,7 @@ fn apply_proxy_env_overrides( // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if // those vars contain SOCKS URLs. We only switch ALL_PROXY here. // - // For attempt-scoped runs, point ALL_PROXY at the HTTP proxy URL so the - // attempt metadata survives in proxy credentials for correlation. - if socks_enabled && network_attempt_id.is_none() { + if socks_enabled { set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url); set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url); } else { @@ -427,14 +420,6 @@ impl NetworkProxy { } pub fn apply_to_env(&self, env: &mut HashMap) { - self.apply_to_env_for_attempt(env, None); - } - - pub fn apply_to_env_for_attempt( - &self, - env: &mut HashMap, - network_attempt_id: Option<&str>, - ) { // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. apply_proxy_env_overrides( @@ -443,7 +428,6 @@ impl NetworkProxy { self.socks_addr, self.socks_enabled, self.allow_local_binding, - network_attempt_id, ); } @@ -751,7 +735,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - None, ); assert_eq!( @@ -802,7 +785,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), false, true, - None, ); assert_eq!( @@ -813,7 +795,7 @@ mod tests { } #[test] - fn apply_proxy_env_overrides_embeds_attempt_id_in_http_proxy_url() { + fn apply_proxy_env_overrides_uses_plain_http_proxy_url() { let mut env = HashMap::new(); apply_proxy_env_overrides( &mut env, @@ -821,28 +803,27 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - Some("attempt-123"), ); assert_eq!( env.get("HTTP_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("HTTPS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("WS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("WSS_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"http://127.0.0.1:3128".to_string()) ); assert_eq!( env.get("ALL_PROXY"), - Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string()) + Some(&"socks5h://127.0.0.1:8081".to_string()) ); #[cfg(target_os = "macos")] assert_eq!( @@ -867,7 +848,6 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), true, false, - None, ); assert_eq!( diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 7917747a8f..238582a6a8 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -75,8 +75,6 @@ pub struct BlockedRequest { pub mode: Option, pub protocol: String, #[serde(skip_serializing_if = "Option::is_none")] - pub attempt_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, @@ -92,7 +90,6 @@ pub struct BlockedRequestArgs { pub method: Option, pub mode: Option, pub protocol: String, - pub attempt_id: Option, pub decision: Option, pub source: Option, pub port: Option, @@ -107,7 +104,6 @@ impl BlockedRequest { method, mode, protocol, - attempt_id, decision, source, port, @@ -119,7 +115,6 @@ impl BlockedRequest { method, mode, protocol, - attempt_id, decision, source, port, @@ -365,7 +360,6 @@ impl NetworkProxyState { let source = entry.source.clone(); let protocol = entry.protocol.clone(); let port = entry.port; - let attempt_id = entry.attempt_id.clone(); guard.blocked.push_back(entry); guard.blocked_total = guard.blocked_total.saturating_add(1); let total = guard.blocked_total; @@ -373,7 +367,7 @@ impl NetworkProxyState { guard.blocked.pop_front(); } debug!( - "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, attempt_id={:?}, buffered={})", + "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})", total, host, reason, @@ -381,7 +375,6 @@ impl NetworkProxyState { source, protocol, port, - attempt_id, guard.blocked.len() ); debug!("{violation_line}"); @@ -700,7 +693,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -741,7 +733,6 @@ mod tests { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), - attempt_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -764,7 +755,6 @@ mod tests { method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), - attempt_id: Some("attempt-1".to_string()), decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -773,7 +763,7 @@ mod tests { assert_eq!( blocked_request_violation_log_line(&entry), - r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","attempt_id":"attempt-1","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"# + r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"# ); } diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 176055a5d2..409bd82e37 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -168,7 +168,6 @@ async fn handle_socks5_tcp( method: None, mode: None, protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -202,7 +201,6 @@ async fn handle_socks5_tcp( method: None, mode: Some(NetworkMode::Limited), protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -229,7 +227,6 @@ async fn handle_socks5_tcp( method: None, command: None, exec_policy_hint: None, - attempt_id: None, }); match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { @@ -254,7 +251,6 @@ async fn handle_socks5_tcp( method: None, mode: None, protocol: "socks5".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -318,7 +314,6 @@ async fn inspect_socks5_udp( method: None, mode: None, protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -352,7 +347,6 @@ async fn inspect_socks5_udp( method: None, mode: Some(NetworkMode::Limited), protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), @@ -375,7 +369,6 @@ async fn inspect_socks5_udp( method: None, command: None, exec_policy_hint: None, - attempt_id: None, }); match evaluate_host_policy(&state, policy_decider.as_ref(), &request).await { @@ -400,7 +393,6 @@ async fn inspect_socks5_udp( method: None, mode: None, protocol: "socks5-udp".to_string(), - attempt_id: None, decision: Some(details.decision.as_str().to_string()), source: Some(details.source.as_str().to_string()), port: Some(port), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 567f18f332..169c8bdcc6 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -122,7 +122,7 @@ impl ApprovalOverlay { || "Would you like to run the following command?".to_string(), |network_approval_context| { format!( - "Do you want to approve access to \"{}\"?", + "Do you want to approve network access to \"{}\"?", network_approval_context.host ) }, @@ -364,12 +364,14 @@ impl From for ApprovalRequestState { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); header.push(Line::from("")); } - let full_cmd = strip_bash_lc_and_escape(&command); - let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); - if let Some(first) = full_cmd_lines.first_mut() { - first.spans.insert(0, Span::from("$ ")); + if network_approval_context.is_none() { + let full_cmd = strip_bash_lc_and_escape(&command); + let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); + if let Some(first) = full_cmd_lines.first_mut() { + first.spans.insert(0, Span::from("$ ")); + } + header.extend(full_cmd_lines); } - header.extend(full_cmd_lines); Self { variant: ApprovalVariant::Exec { id, @@ -738,11 +740,15 @@ mod tests { .collect(); assert!( - rendered - .iter() - .any(|line| line.contains("Do you want to approve access to \"example.com\"?")), + rendered.iter().any(|line| { + line.contains("Do you want to approve network access to \"example.com\"?") + }), "expected network title to include host, got {rendered:?}" ); + assert!( + !rendered.iter().any(|line| line.contains("$ curl")), + "network prompt should not show command line, got {rendered:?}" + ); assert!( !rendered.iter().any(|line| line.contains("don't ask again")), "network prompt should not show execpolicy option, got {rendered:?}" From 73fd93929699375e5231e4854083195588deae52 Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 20 Feb 2026 10:47:07 -0800 Subject: [PATCH 20/39] js_repl: block wrapped payload prefixes in grammar (#12300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Tighten the `js_repl` freeform Lark grammar to block the most common malformed payload wrappers before they reach runtime validation. ## What Changed - Replaced the overly permissive `js_repl` freeform grammar (`start: /[\s\S]*/`) with a structured grammar that still supports: - plain JS source - optional first-line `// codex-js-repl:` pragma followed by JS source - Added grammar-level filtering for common bad payload shapes by rejecting inputs whose first significant token starts with: - `{` (JSON object wrapper like `{"code":"..."}`) - `"` (quoted code string) - `` ``` `` (markdown code fences) - Implemented the grammar without regex lookahead/lookbehind because the API-side Lark regex engine does not support look-around. - Added a unit test to validate the grammar shape and guard against reintroducing unsupported lookaround. ## Why `js_repl` is a freeform tool, but the model sometimes emits wrapped payloads (JSON, quoted strings, markdown fences) instead of raw JavaScript. We already reject those at runtime, but this change moves the constraint into the tool grammar so the model is less likely to generate invalid tool-call payloads in the first place. ## Testing - `cargo test -p codex-core js_repl_freeform_grammar_blocks_common_non_js_prefixes` - `cargo test -p codex-core parse_freeform_args_rejects_` ## Notes - This intentionally over-blocks a few uncommon valid JS starts (for example top-level `{ ... }` blocks or top-level quoted directives like `"use strict";`) in exchange for preventing the common wrapped-payload mistakes. #### [git stack](https://github.com/magus/git-stack-cli) - 👉 `1` https://github.com/openai/codex/pull/12300 - ⏳ `2` https://github.com/openai/codex/pull/12275 - ⏳ `3` https://github.com/openai/codex/pull/12205 - ⏳ `4` https://github.com/openai/codex/pull/12185 - ⏳ `5` https://github.com/openai/codex/pull/10673 --- codex-rs/core/src/tools/spec.rs | 34 ++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index b17a3a2850..fe9c7ee8a5 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1068,7 +1068,24 @@ fn create_list_dir_tool() -> ToolSpec { } fn create_js_repl_tool() -> ToolSpec { - const JS_REPL_FREEFORM_GRAMMAR: &str = r#"start: /[\s\S]*/"#; + // Keep JS input freeform, but block the most common malformed payload shapes + // (JSON wrappers, quoted strings, and markdown fences) before they reach the + // runtime `reject_json_or_quoted_source` validation. The API's regex engine + // does not support look-around, so this uses a "first significant token" + // pattern rather than negative lookaheads. + const JS_REPL_FREEFORM_GRAMMAR: &str = r#" +start: pragma_source | plain_source + +pragma_source: PRAGMA_LINE NEWLINE js_source +plain_source: PLAIN_JS_SOURCE + +js_source: JS_SOURCE + +PRAGMA_LINE: /[ \t]*\/\/ codex-js-repl:[^\r\n]*/ +NEWLINE: /\r?\n/ +PLAIN_JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ +JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ +"#; ToolSpec::Freeform(FreeformTool { name: "js_repl".to_string(), @@ -1922,6 +1939,21 @@ mod tests { assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); } + #[test] + fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() { + let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { + panic!("js_repl should use a freeform tool spec"); + }; + + assert_eq!(format.syntax, "lark"); + assert!(format.definition.contains("PRAGMA_LINE")); + assert!(format.definition.contains("`[^`]")); + assert!(format.definition.contains("``[^`]")); + assert!(format.definition.contains("PLAIN_JS_SOURCE")); + assert!(format.definition.contains("codex-js-repl:")); + assert!(!format.definition.contains("(?!")); + } + fn assert_model_tools( model_slug: &str, features: &Features, From 28c0089060b06bf73118ba906f198a1ca8630ad6 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 10:56:57 -0800 Subject: [PATCH 21/39] fix(network-proxy): add unix socket allow-all and update seatbelt rules (#11368) ## Summary Adds support for a Unix socket escape hatch so we can bypass socket allowlisting when explicitly enabled. ## Description * added a new flag, `network.dangerously_allow_all_unix_sockets` as an explicit escape hatch * In codex-network-proxy, enabling that flag now allows any absolute Unix socket path from x-unix-socket instead of requiring each path to be explicitly allowlisted. Relative paths are still rejected. * updated the macOS seatbelt path in core so it enforces the same Unix socket behavior: * allowlisted sockets generate explicit network* subpath rules * allow-all generates a broad network* (subpath "/") rule --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- .../codex_app_server_protocol.schemas.json | 6 + .../v2/ConfigRequirementsReadResponse.json | 6 + .../typescript/v2/NetworkRequirements.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/src/config_api.rs | 3 + codex-rs/config/src/config_requirements.rs | 9 + codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/mod.rs | 1 + .../core/src/config/network_proxy_spec.rs | 7 + codex-rs/core/src/config/permissions.rs | 4 + codex-rs/core/src/network_proxy_loader.rs | 23 ++ codex-rs/core/src/seatbelt.rs | 233 +++++++++++++++++- codex-rs/network-proxy/README.md | 10 +- codex-rs/network-proxy/src/config.rs | 107 +++++++- codex-rs/network-proxy/src/http_proxy.rs | 4 +- codex-rs/network-proxy/src/proxy.rs | 22 +- codex-rs/network-proxy/src/runtime.rs | 103 +++++++- codex-rs/network-proxy/src/state.rs | 21 ++ codex-rs/tui/src/debug_config.rs | 6 + 19 files changed, 553 insertions(+), 18 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 e1e0137b7f..5a840f0bde 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 @@ -13466,6 +13466,12 @@ "null" ] }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowNonLoopbackAdmin": { "type": [ "boolean", 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 4d8a967375..651e035e9c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -117,6 +117,12 @@ "null" ] }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowNonLoopbackAdmin": { "type": [ "boolean", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index b7ac9d2f7a..6205de1f4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dfb185b40a..2641d7c2a3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -577,6 +577,7 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index e1531dce2f..c9317ac9f5 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -161,6 +161,7 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin: network.dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, allowed_domains: network.allowed_domains, denied_domains: network.denied_domains, allow_unix_sockets: network.allow_unix_sockets, @@ -221,6 +222,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), @@ -258,6 +260,7 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_non_loopback_admin: Some(false), + dangerously_allow_all_unix_sockets: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index c6b63258f2..db81b45e35 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -135,6 +135,7 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -150,6 +151,7 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -165,6 +167,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -177,6 +180,7 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -1040,6 +1044,7 @@ mod tests { [experimental_network] enabled = true allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] @@ -1058,6 +1063,10 @@ mod tests { assert_eq!(sourced_network.source, source); assert_eq!(sourced_network.value.enabled, Some(true)); assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); assert_eq!( sourced_network.value.allowed_domains.as_ref(), Some(&vec![ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5eab764fc3..732f9590f7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -656,6 +656,9 @@ }, "type": "array" }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, "dangerously_allow_non_loopback_admin": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a27508d1a9..1675067c7a 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2401,6 +2401,7 @@ allowed_domains = ["openai.com"] allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_non_loopback_admin: None, + dangerously_allow_all_unix_sockets: None, mode: None, allowed_domains: Some(vec!["openai.com".to_string()]), denied_domains: None, diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 1957455d6f..cd65a4dc41 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -178,6 +178,13 @@ impl NetworkProxySpec { constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = + requirements.dangerously_allow_all_unix_sockets + { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + constraints.dangerously_allow_all_unix_sockets = + Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = requirements.allowed_domains.clone() { config.network.allowed_domains = allowed_domains.clone(); constraints.allowed_domains = Some(allowed_domains); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a03b5273da..dd242bd857 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -24,6 +24,7 @@ pub struct NetworkToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, pub allowed_domains: Option>, @@ -74,6 +75,9 @@ impl NetworkToml { config.network.dangerously_allow_non_loopback_admin = dangerously_allow_non_loopback_admin; } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } if let Some(mode) = self.mode { config.network.mode = mode; } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 6fcf91f086..1af36b2acb 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -133,6 +133,9 @@ fn apply_network_constraints(network: NetworkToml, constraints: &mut NetworkProx constraints.dangerously_allow_non_loopback_admin = Some(dangerously_allow_non_loopback_admin); } + if let Some(dangerously_allow_all_unix_sockets) = network.dangerously_allow_all_unix_sockets { + constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); + } if let Some(allowed_domains) = network.allowed_domains { constraints.allowed_domains = Some(allowed_domains); } @@ -288,4 +291,24 @@ allowed_domains = ["higher.example.com"] assert_eq!(config.network.allowed_domains, vec!["higher.example.com"]); } + + #[test] + fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { + let config: toml::Value = toml::from_str( + r#" +[network] +dangerously_allow_all_unix_sockets = true +"#, + ) + .expect("network table should parse"); + let network = network_tables_from_toml(&config) + .expect("network table should deserialize") + .network + .expect("network table should be present"); + + let mut constraints = NetworkProxyConstraints::default(); + apply_network_constraints(network, &mut constraints); + + assert_eq!(constraints.dangerously_allow_all_unix_sockets, Some(true)); + } } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 03cb45d918..e9d8879f85 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,16 +1,18 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::ALLOW_LOCAL_BINDING_ENV_KEY; use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; use codex_network_proxy::proxy_url_env_value; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; +use tracing::warn; use url::Url; use crate::protocol::SandboxPolicy; @@ -110,34 +112,131 @@ fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { ports.into_iter().collect() } -fn local_binding_enabled(env: &HashMap) -> bool { - env.get(ALLOW_LOCAL_BINDING_ENV_KEY).is_some_and(|value| { - let trimmed = value.trim(); - trimmed == "1" || trimmed.eq_ignore_ascii_case("true") - }) -} - #[derive(Debug, Default)] struct ProxyPolicyInputs { ports: Vec, has_proxy_config: bool, allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + key: String, + path: AbsolutePathBuf, } fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { if let Some(network) = network { let mut env = HashMap::new(); network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; return ProxyPolicyInputs { ports: proxy_loopback_ports_from_env(&env), has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: local_binding_enabled(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, }; } ProxyPolicyInputs::default() } +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current + // working directory, so keep the explicit check to avoid silently accepting relative entries. + if !path.is_absolute() { + return None; + } + + let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; + let normalized_path = absolute_path + .as_path() + .canonicalize() + .ok() + .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); + normalized_path.or(Some(absolute_path)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { + key: format!("UNIX_SOCKET_PATH_{index}"), + path, + }) + .collect() +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| (param.key, param.path.into_path_buf())) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { + if matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) { + return "(allow network* (subpath \"/\"))\n".to_string(); + } + + unix_socket_path_params(proxy) + .iter() + .map(|param| format!("(allow network* (subpath (param \"{}\")))\n", param.key)) + .collect() +} + fn dynamic_network_policy( sandbox_policy: &SandboxPolicy, enforce_managed_network: bool, @@ -157,6 +256,11 @@ fn dynamic_network_policy( "(allow network-outbound (remote ip \"localhost:{port}\"))\n" )); } + let unix_socket_policy = unix_socket_policy(proxy); + if !unix_socket_policy.is_empty() { + policy.push_str("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); } @@ -339,6 +443,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( file_write_dir_params, unix_socket_params, macos_dir_params(), + unix_socket_dir_params(&proxy), seatbelt_extensions.dir_params, ] .concat(); @@ -404,15 +509,20 @@ fn macos_dir_params() -> Vec<(String, PathBuf)> { mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; + use super::UnixDomainSocketPolicy; use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; use super::macos_dir_params; + use super::normalize_path_for_sandbox; + use super::unix_socket_dir_params; + use super::unix_socket_policy; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt_permissions::MacOsAutomationPermission; use crate::seatbelt_permissions::MacOsPreferencesPermission; use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -430,6 +540,10 @@ mod tests { ); } + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(Path::new(path)).expect("absolute path") + } + #[test] fn base_policy_allows_node_cpu_sysctls() { assert!( @@ -451,6 +565,7 @@ mod tests { ports: vec![43128, 48081], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -574,6 +689,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: true, + ..ProxyPolicyInputs::default() }, ); @@ -610,6 +726,7 @@ mod tests { ports: vec![], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); @@ -638,12 +755,109 @@ mod tests { ports: vec![], has_proxy_config: false, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); assert_eq!(policy, ""); } + #[test] + fn create_seatbelt_args_allowlists_unix_socket_paths() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + }, + ); + + assert!( + policy.contains("(allow network* (subpath (param \"UNIX_SOCKET_PATH_0\")))"), + "policy should allow explicitly configured unix sockets:\n{policy}" + ); + } + + #[test] + fn unix_socket_policy_non_empty_output_is_newline_terminated() { + let allowlist_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![absolute_path("/tmp/example.sock")], + }, + ..ProxyPolicyInputs::default() + }); + assert!( + allowlist_policy.ends_with('\n'), + "allowlist unix socket policy should end with a newline:\n{allowlist_policy}" + ); + + let allow_all_policy = unix_socket_policy(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + ..ProxyPolicyInputs::default() + }); + assert!( + allow_all_policy.ends_with('\n'), + "allow-all unix socket policy should end with a newline:\n{allow_all_policy}" + ); + } + + #[test] + fn unix_socket_dir_params_use_stable_param_names() { + let params = unix_socket_dir_params(&ProxyPolicyInputs { + unix_domain_socket_policy: UnixDomainSocketPolicy::Restricted { + allowed: vec![ + absolute_path("/tmp/b.sock"), + absolute_path("/tmp/a.sock"), + absolute_path("/tmp/a.sock"), + ], + }, + ..ProxyPolicyInputs::default() + }); + + assert_eq!( + params, + vec![ + ( + "UNIX_SOCKET_PATH_0".to_string(), + PathBuf::from("/tmp/a.sock") + ), + ( + "UNIX_SOCKET_PATH_1".to_string(), + PathBuf::from("/tmp/b.sock") + ), + ] + ); + } + + #[test] + fn normalize_path_for_sandbox_rejects_relative_paths() { + assert_eq!(normalize_path_for_sandbox(Path::new("relative.sock")), None); + } + + #[test] + fn create_seatbelt_args_allows_all_unix_sockets_when_enabled() { + let policy = dynamic_network_policy( + &SandboxPolicy::new_read_only_policy(), + false, + &ProxyPolicyInputs { + ports: vec![43128], + has_proxy_config: true, + allow_local_binding: false, + unix_domain_socket_policy: UnixDomainSocketPolicy::AllowAll, + }, + ); + + assert!( + policy.contains("(allow network* (subpath \"/\"))"), + "policy should allow all unix sockets when flag is enabled:\n{policy}" + ); + } + #[test] fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { let policy = dynamic_network_policy( @@ -659,6 +873,7 @@ mod tests { ports: vec![43128], has_proxy_config: true, allow_local_binding: false, + ..ProxyPolicyInputs::default() }, ); diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 925d7147bc..198dbbd617 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -47,6 +47,9 @@ allow_local_binding = true # macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. allow_unix_sockets = ["/tmp/example.sock"] +# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any +# absolute socket path from `x-unix-socket`. +dangerously_allow_all_unix_sockets = false ``` ### 2) Run the proxy @@ -116,8 +119,9 @@ let handle = proxy.run().await?; handle.shutdown().await?; ``` -When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback -to avoid turning the proxy into a remote bridge to local daemons. +When unix socket proxying is enabled (`allow_unix_sockets` or +`dangerously_allow_all_unix_sockets`), HTTP/admin bind overrides are still clamped to loopback to +avoid turning the proxy into a remote bridge to local daemons. ### Policy hook (exec-policy mapping) @@ -176,6 +180,8 @@ what it can reasonably guarantee. `dangerously_allow_non_loopback_proxy` - when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the proxy into a remote bridge into local daemons. +- `dangerously_allow_all_unix_sockets = true` bypasses the unix socket allowlist entirely (still + macOS-only and absolute-path-only). Use only in tightly controlled environments. - `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. Limitations: diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 31197a1c96..3284822232 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -1,10 +1,12 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; use std::net::IpAddr; use std::net::SocketAddr; +use std::path::Path; use tracing::warn; use url::Url; @@ -33,6 +35,8 @@ pub struct NetworkProxySettings { #[serde(default)] pub dangerously_allow_non_loopback_admin: bool, #[serde(default)] + pub dangerously_allow_all_unix_sockets: bool, + #[serde(default)] pub mode: NetworkMode, #[serde(default)] pub allowed_domains: Vec, @@ -55,6 +59,7 @@ impl Default for NetworkProxySettings { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::default(), allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -136,7 +141,7 @@ pub(crate) fn clamp_bind_addrs( cfg.dangerously_allow_non_loopback_admin, "admin API", ); - if cfg.allow_unix_sockets.is_empty() { + if cfg.allow_unix_sockets.is_empty() && !cfg.dangerously_allow_all_unix_sockets { return (http_addr, socks_addr, admin_addr); } @@ -172,7 +177,49 @@ pub struct RuntimeConfig { pub admin_addr: SocketAddr, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UnixStyleAbsolutePath(String); + +impl UnixStyleAbsolutePath { + fn parse(value: &str) -> Option { + value.starts_with('/').then(|| Self(value.to_string())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ValidatedUnixSocketPath { + Native(AbsolutePathBuf), + UnixStyleAbsolute(UnixStyleAbsolutePath), +} + +impl ValidatedUnixSocketPath { + pub(crate) fn parse(socket_path: &str) -> Result { + let path = Path::new(socket_path); + if path.is_absolute() { + let path = AbsolutePathBuf::from_absolute_path(path) + .with_context(|| format!("failed to normalize unix socket path {socket_path:?}"))?; + return Ok(Self::Native(path)); + } + + if let Some(path) = UnixStyleAbsolutePath::parse(socket_path) { + return Ok(Self::UnixStyleAbsolute(path)); + } + + bail!("expected an absolute path, got {socket_path:?}"); + } +} + +pub(crate) fn validate_unix_socket_allowlist_paths(cfg: &NetworkProxyConfig) -> Result<()> { + for (index, socket_path) in cfg.network.allow_unix_sockets.iter().enumerate() { + ValidatedUnixSocketPath::parse(socket_path) + .with_context(|| format!("invalid network.allow_unix_sockets[{index}]"))?; + } + Ok(()) +} + pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + validate_unix_socket_allowlist_paths(cfg)?; + let http_addr = resolve_addr(&cfg.network.proxy_url, 3128) .with_context(|| format!("invalid network.proxy_url: {}", cfg.network.proxy_url))?; let socks_addr = resolve_addr(&cfg.network.socks_url, 8081) @@ -340,6 +387,7 @@ mod tests { allow_upstream_proxy: true, dangerously_allow_non_loopback_proxy: false, dangerously_allow_non_loopback_admin: false, + dangerously_allow_all_unix_sockets: false, mode: NetworkMode::Full, allowed_domains: Vec::new(), denied_domains: Vec::new(), @@ -526,4 +574,61 @@ mod tests { assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_all_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + dangerously_allow_all_unix_sockets: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let socks_addr = "0.0.0.0:8081".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, socks_addr, admin_addr) = + clamp_bind_addrs(http_addr, socks_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(socks_addr, "127.0.0.1:8081".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } + + #[test] + fn resolve_runtime_rejects_relative_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["relative.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + let err = match resolve_runtime(&cfg) { + Ok(runtime) => panic!( + "relative allow_unix_sockets should fail, but resolve_runtime succeeded: {:?}", + runtime.http_addr + ), + Err(err) => err, + }; + assert!( + err.to_string().contains("network.allow_unix_sockets[0]"), + "error should point at the invalid allow_unix_sockets entry: {err:#}" + ); + } + + #[test] + fn resolve_runtime_accepts_unix_style_absolute_allow_unix_sockets_entries() { + let cfg = NetworkProxyConfig { + network: NetworkProxySettings { + allow_unix_sockets: vec!["/private/tmp/example.sock".to_string()], + ..NetworkProxySettings::default() + }, + }; + + assert!( + resolve_runtime(&cfg).is_ok(), + "unix-style absolute allow_unix_sockets entry should be accepted" + ); + } } diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index d02e02bcff..7ea88ba34c 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -378,8 +378,8 @@ async fn http_plain_proxy( }; // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: - // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability - // escalation mechanism. + // macOS-only + explicit allowlist by default, to avoid turning the proxy into a general local + // capability escalation mechanism. if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { let socket_path = match unix_socket_header.to_str() { Ok(value) => value.to_string(), diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ba385dbba4..f30006958f 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -206,6 +206,10 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, allow_local_binding: current_cfg.network.allow_local_binding, + allow_unix_sockets: current_cfg.network.allow_unix_sockets.clone(), + dangerously_allow_all_unix_sockets: current_cfg + .network + .dangerously_allow_all_unix_sockets, admin_addr, reserved_listeners, policy_decider: self.policy_decider, @@ -240,6 +244,8 @@ pub struct NetworkProxy { socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, + allow_unix_sockets: Vec, + dangerously_allow_all_unix_sockets: bool, admin_addr: SocketAddr, reserved_listeners: Option>, policy_decider: Option>, @@ -419,6 +425,18 @@ impl NetworkProxy { self.admin_addr } + pub fn allow_local_binding(&self) -> bool { + self.allow_local_binding + } + + pub fn allow_unix_sockets(&self) -> &[String] { + &self.allow_unix_sockets + } + + pub fn dangerously_allow_all_unix_sockets(&self) -> bool { + self.dangerously_allow_all_unix_sockets + } + pub fn apply_to_env(&self, env: &mut HashMap) { // Enforce proxying for child processes. We intentionally override existing values so // command-level environment cannot bypass the managed proxy endpoint. @@ -441,7 +459,9 @@ impl NetworkProxy { ensure_rustls_crypto_provider(); if !unix_socket_permissions_supported() { - warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + warn!( + "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform" + ); } let reserved_listeners = self.reserved_listeners.as_ref(); diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index 238582a6a8..583ecd7eda 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -1,5 +1,6 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; +use crate::config::ValidatedUnixSocketPath; use crate::policy::Host; use crate::policy::is_loopback_host; use crate::policy::is_non_public_ip; @@ -418,6 +419,10 @@ impl NetworkProxyState { } let guard = self.state.read().await; + if guard.config.network.dangerously_allow_all_unix_sockets { + return Ok(true); + } + // Normalize the path while keeping the absolute-path requirement explicit. let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { Ok(path) => path, @@ -425,7 +430,16 @@ impl NetworkProxyState { }; let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); for allowed in &guard.config.network.allow_unix_sockets { - if allowed == path { + let allowed_path = match ValidatedUnixSocketPath::parse(allowed) { + Ok(ValidatedUnixSocketPath::Native(path)) => path, + Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue, + Err(err) => { + warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}"); + continue; + } + }; + + if allowed_path.as_path() == requested_abs.as_path() { return Ok(true); } @@ -434,7 +448,7 @@ impl NetworkProxyState { let Some(requested_canonical) = &requested_canonical else { continue; }; - if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path()) && &allowed_canonical == requested_canonical { return Ok(true); @@ -1075,6 +1089,77 @@ mod tests { assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); } + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in() + { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed() + { + let constraints = NetworkProxyConstraints { + allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_all_unix_sockets: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() { + let constraints = NetworkProxyConstraints::default(); + + let config = NetworkProxyConfig { + network: NetworkProxySettings { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; @@ -1172,6 +1257,19 @@ mod tests { assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); } + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allow_all_flag_bypasses_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["example.com".to_string()], + dangerously_allow_all_unix_sockets: true, + ..NetworkProxySettings::default() + }); + + assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap()); + assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap()); + } + #[cfg(not(target_os = "macos"))] #[tokio::test] async fn unix_socket_allowlist_is_rejected_on_non_macos() { @@ -1179,6 +1277,7 @@ mod tests { let state = network_proxy_state_for_policy(NetworkProxySettings { allowed_domains: vec!["example.com".to_string()], allow_unix_sockets: vec![socket_path.clone()], + dangerously_allow_all_unix_sockets: true, ..NetworkProxySettings::default() }); diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 97dfcc4b5d..d7dc705ac4 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -19,6 +19,7 @@ pub struct NetworkProxyConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, pub allowed_domains: Option>, pub denied_domains: Option>, pub allow_unix_sockets: Option>, @@ -38,6 +39,7 @@ pub struct PartialNetworkConfig { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_non_loopback_admin: Option, + pub dangerously_allow_all_unix_sockets: Option, #[serde(default)] pub allowed_domains: Option>, #[serde(default)] @@ -52,6 +54,7 @@ pub fn build_config_state( config: NetworkProxyConfig, constraints: NetworkProxyConstraints, ) -> anyhow::Result { + crate::config::validate_unix_socket_allowlist_paths(&config)?; let deny_set = compile_globset(&config.network.denied_domains)?; let allow_set = compile_globset(&config.network.allowed_domains)?; Ok(ConfigState { @@ -173,6 +176,24 @@ pub fn validate_policy_against_constraints( }, )?; + let allow_all_unix_sockets = constraints + .dangerously_allow_all_unix_sockets + .unwrap_or(constraints.allow_unix_sockets.is_none()); + validate( + config.network.dangerously_allow_all_unix_sockets, + move |candidate| { + if *candidate && !allow_all_unix_sockets { + Err(invalid_value( + "network.dangerously_allow_all_unix_sockets", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + if let Some(allow_local_binding) = constraints.allow_local_binding { validate(config.network.allow_local_binding, move |candidate| { if *candidate && !allow_local_binding { diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index a3f32d298d..38faf2d35b 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -332,6 +332,7 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_non_loopback_admin, + dangerously_allow_all_unix_sockets, allowed_domains, denied_domains, allow_unix_sockets, @@ -360,6 +361,11 @@ fn format_network_constraints(network: &NetworkConstraints) -> String { "dangerously_allow_non_loopback_admin={dangerously_allow_non_loopback_admin}" )); } + if let Some(dangerously_allow_all_unix_sockets) = dangerously_allow_all_unix_sockets { + parts.push(format!( + "dangerously_allow_all_unix_sockets={dangerously_allow_all_unix_sockets}" + )); + } if let Some(allowed_domains) = allowed_domains { parts.push(format!("allowed_domains=[{}]", allowed_domains.join(", "))); } From 097620218d2cc7ca310a8df21cf59243ddf32d0a Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 20 Feb 2026 11:20:45 -0800 Subject: [PATCH 22/39] js_repl: remove codex.state helper references (#12275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR removes `codex.state` from the `js_repl` helper surface and removes all corresponding documentation/instruction references. ## Motivation Top-level bindings in `js_repl` now persist across cells, so the extra `codex.state` helper is redundant and adds unnecessary API/docs surface. ## Changes - Removed the long-lived `state` object from the Node kernel helper wiring. - Stopped exposing `codex.state` (and `context.state`) during `js_repl` execution. - Updated user-facing `js_repl` docs to remove `codex.state`. - Updated generated instruction text and related test expectations to list only: - `codex.tmpDir` - `codex.tool(name, args?)` #### [git stack](https://github.com/magus/git-stack-cli) - ✅ `1` https://github.com/openai/codex/pull/12300 - 👉 `2` https://github.com/openai/codex/pull/12275 - ⏳ `3` https://github.com/openai/codex/pull/12205 - ⏳ `4` https://github.com/openai/codex/pull/12185 - ⏳ `5` https://github.com/openai/codex/pull/10673 --- codex-rs/core/src/project_doc.rs | 10 ++++++---- codex-rs/core/src/tools/js_repl/kernel.js | 6 +----- docs/js_repl.md | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 3d2b55ab5b..0147a766fe 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -40,9 +40,11 @@ fn render_js_repl_instructions(config: &Config) -> Option { } let mut section = String::from("## JavaScript REPL (Node)\n"); - section.push_str("- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n"); + section.push_str( + "- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n", + ); section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); - section.push_str("- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n"); + section.push_str("- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n"); section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n"); section.push_str("- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n"); section.push_str("- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n"); @@ -415,7 +417,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; assert_eq!(res, expected); } @@ -430,7 +432,7 @@ mod tests { let res = get_user_instructions(&cfg, None) .await .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset`.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.state`, `codex.tmpDir`, and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; + let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir` and `codex.tool(name, args?)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike.\n- To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool(\"view_image\", { path: \"/absolute/path\" })`, then delete the file.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log` and `codex.tool(...)`."; assert_eq!(res, expected); } diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js index 67a1f23a8b..283a9f4516 100644 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ b/codex-rs/core/src/tools/js_repl/kernel.js @@ -115,9 +115,6 @@ function isDeniedBuiltin(specifier) { const pendingTool = new Map(); let toolCounter = 0; const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd(); -// Explicit long-lived mutable store exposed as `codex.state`. This is useful -// when callers want shared state without relying on lexical binding carry-over. -const state = {}; const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; const moduleSearchBases = (() => { const bases = []; @@ -470,8 +467,7 @@ async function handleExec(message) { const { source, nextBindings } = await buildModuleSource(code); let output = ""; - context.state = state; - context.codex = { state, tmpDir, tool }; + context.codex = { tmpDir, tool }; context.tmpDir = tmpDir; await withCapturedConsole(context, async (logs) => { diff --git a/docs/js_repl.md b/docs/js_repl.md index a9b8b7228e..eb0ea84ffd 100644 --- a/docs/js_repl.md +++ b/docs/js_repl.md @@ -63,7 +63,6 @@ For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module reso `js_repl` exposes these globals: -- `codex.state`: mutable object persisted for the current kernel session. - `codex.tmpDir`: per-session scratch directory path. - `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available). - To share generated images with the model, write a file under `codex.tmpDir`, call `await codex.tool("view_image", { path: "/absolute/path" })`, then delete the file. From f08cf8d65f552d06c48eb7935acf9b6dfe17fb4c Mon Sep 17 00:00:00 2001 From: daniel-oai Date: Fri, 20 Feb 2026 11:35:28 -0800 Subject: [PATCH 23/39] CODEX-4927: Surface local login entitlement denials in browser (#12289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Users without Codex access can hit a confusing local login loop. In the denial case, the callback could fall through to generic behavior (including a plain "Missing authorization code" page) instead of clearly explaining that access was denied. Screenshot 2026-02-19 at 11 43 45 PM Screenshot 2026-02-19 at 11 44 53 PM ## Scope This PR improves local login error clarity only. It does not change entitlement policy, RBAC rules, or who is allowed to use Codex. ## What Changed - The local OAuth callback handler now parses `error` and `error_description` on `/auth/callback` and exits the callback loop with a real failure. - Callback failures render a branded local Codex error page instead of a generic/plain page. - `access_denied` + `missing_codex_entitlement` is now mapped to an explicit user-facing message telling the user Codex is not enabled for their workspace and to contact their workspace administrator for access. - Unknown OAuth callback errors continue to use a generic error page while preserving the OAuth error code/details for debugging. - Added the login error page template to Bazel assets so the local binary can render it in Bazel builds. ## Non-goals - No TUI onboarding/toast changes in this PR. - No backend entitlement or policy changes. ## Tests - Added an end-to-end `codex-login` test for `access_denied` + `missing_codex_entitlement` and verified the page shows the actionable admin guidance. - Added an end-to-end `codex-login` test for a generic `access_denied` reason to verify we keep a generic fallback page/message. --- codex-rs/login/BUILD.bazel | 5 +- codex-rs/login/src/assets/error.html | 122 +++++++++++ codex-rs/login/src/server.rs | 201 ++++++++++++++++-- .../login/tests/suite/login_server_e2e.rs | 146 +++++++++++++ 4 files changed, 450 insertions(+), 24 deletions(-) create mode 100644 codex-rs/login/src/assets/error.html diff --git a/codex-rs/login/BUILD.bazel b/codex-rs/login/BUILD.bazel index 127cd60fd9..fe5866577f 100644 --- a/codex-rs/login/BUILD.bazel +++ b/codex-rs/login/BUILD.bazel @@ -3,5 +3,8 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "login", crate_name = "codex_login", - compile_data = ["src/assets/success.html"], + compile_data = [ + "src/assets/error.html", + "src/assets/success.html", + ], ) diff --git a/codex-rs/login/src/assets/error.html b/codex-rs/login/src/assets/error.html new file mode 100644 index 0000000000..96c1eb303c --- /dev/null +++ b/codex-rs/login/src/assets/error.html @@ -0,0 +1,122 @@ + + + + + Codex Sign-in Error + + + + +
+
+
+ +
Codex login
+
+ +

__ERROR_TITLE__

+

__ERROR_MESSAGE__

+ +
+
+ Error code + __ERROR_CODE__ +
+
+ Details + __ERROR_DESCRIPTION__ +
+
+ +

__ERROR_HELP__

+
+
+ + diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index d97de008e6..53b4ad3601 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1,3 +1,6 @@ +//! Local OAuth callback server for CLI login. +//! +//! This module runs the short-lived localhost server used by interactive sign-in. use std::io::Cursor; use std::io::Read; use std::io::Write; @@ -32,6 +35,7 @@ use tiny_http::StatusCode; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +/// Options for launching the local login callback server. #[derive(Debug, Clone)] pub struct ServerOptions { pub codex_home: PathBuf, @@ -45,6 +49,7 @@ pub struct ServerOptions { } impl ServerOptions { + /// Creates a server configuration with the default issuer and port. pub fn new( codex_home: PathBuf, client_id: String, @@ -64,6 +69,7 @@ impl ServerOptions { } } +/// Handle for a running login callback server. pub struct LoginServer { pub auth_url: String, pub actual_port: u16, @@ -72,32 +78,38 @@ pub struct LoginServer { } impl LoginServer { + /// Waits for the login callback loop to finish. pub async fn block_until_done(self) -> io::Result<()> { self.server_handle .await .map_err(|err| io::Error::other(format!("login server thread panicked: {err:?}")))? } + /// Requests shutdown of the callback server. pub fn cancel(&self) { self.shutdown_handle.shutdown(); } + /// Returns a cloneable cancel handle for the running server. pub fn cancel_handle(&self) -> ShutdownHandle { self.shutdown_handle.clone() } } +/// Handle used to signal the login server loop to exit. #[derive(Clone, Debug)] pub struct ShutdownHandle { shutdown_notify: Arc, } impl ShutdownHandle { + /// Signals the login loop to terminate. pub fn shutdown(&self) { self.shutdown_notify.notify_waiters(); } } +/// Starts a local callback server and returns the browser auth URL. pub fn run_login_server(opts: ServerOptions) -> io::Result { let pkce = generate_pkce(); let state = opts.force_state.clone().unwrap_or_else(generate_state); @@ -207,6 +219,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { }) } +/// Internal callback handling outcome. enum HandledRequest { Response(Response>>), RedirectWithHeader(Header), @@ -245,11 +258,25 @@ async fn process_request( Response::from_string("State mismatch").with_status_code(400), ); } + if let Some(error_code) = params.get("error") { + let error_description = params.get("error_description").map(String::as_str); + let message = oauth_callback_error_message(error_code, error_description); + eprintln!("OAuth callback error: {message}"); + return login_error_response( + &message, + io::ErrorKind::PermissionDenied, + Some(error_code), + error_description, + ); + } let code = match params.get("code") { Some(c) if !c.is_empty() => c.clone(), _ => { - return HandledRequest::Response( - Response::from_string("Missing authorization code").with_status_code(400), + return login_error_response( + "Missing authorization code. Sign-in could not be completed.", + io::ErrorKind::InvalidData, + Some("missing_authorization_code"), + None, ); } }; @@ -263,7 +290,12 @@ async fn process_request( &tokens.id_token, ) { eprintln!("Workspace restriction error: {message}"); - return login_error_response(&message); + return login_error_response( + &message, + io::ErrorKind::PermissionDenied, + Some("workspace_restriction"), + None, + ); } // Obtain API key via token-exchange and persist let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) @@ -280,9 +312,11 @@ async fn process_request( .await { eprintln!("Persist error: {err}"); - return HandledRequest::Response( - Response::from_string(format!("Unable to persist auth file: {err}")) - .with_status_code(500), + return login_error_response( + "Sign-in completed but credentials could not be saved locally.", + io::ErrorKind::Other, + Some("persist_failed"), + Some(&err.to_string()), ); } @@ -294,16 +328,21 @@ async fn process_request( ); match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { Ok(header) => HandledRequest::RedirectWithHeader(header), - Err(_) => HandledRequest::Response( - Response::from_string("Internal Server Error").with_status_code(500), + Err(_) => login_error_response( + "Sign-in completed but redirecting back to Codex failed.", + io::ErrorKind::Other, + Some("redirect_failed"), + None, ), } } Err(err) => { eprintln!("Token exchange error: {err}"); - HandledRequest::Response( - Response::from_string(format!("Token exchange failed: {err}")) - .with_status_code(500), + login_error_response( + &format!("Token exchange failed: {err}"), + io::ErrorKind::Other, + Some("token_exchange_failed"), + None, ) } } @@ -486,12 +525,14 @@ fn bind_server(port: u16) -> io::Result { } } +/// Tokens returned by the OAuth authorization-code exchange. pub(crate) struct ExchangedTokens { pub id_token: String, pub access_token: String, pub refresh_token: String, } +/// Exchanges an authorization code for tokens. pub(crate) async fn exchange_code_for_tokens( issuer: &str, client_id: &str, @@ -521,10 +562,12 @@ pub(crate) async fn exchange_code_for_tokens( .await .map_err(io::Error::other)?; - if !resp.status().is_success() { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.map_err(io::Error::other)?; + let detail = parse_token_endpoint_error(&body); return Err(io::Error::other(format!( - "token endpoint returned status {}", - resp.status() + "token endpoint returned status {status}: {detail}" ))); } @@ -536,6 +579,7 @@ pub(crate) async fn exchange_code_for_tokens( }) } +/// Persists exchanged credentials using the configured local auth store. pub(crate) async fn persist_tokens_async( codex_home: &Path, api_key: Option, @@ -650,6 +694,7 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { serde_json::Map::new() } +/// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( expected: Option<&str>, id_token: &str, @@ -670,23 +715,133 @@ pub(crate) fn ensure_workspace_allowed( } } -// Respond to the oauth server with an error so the code becomes unusable by anybody else. -fn login_error_response(message: &str) -> HandledRequest { +/// Builds a terminal callback response for login failures. +fn login_error_response( + message: &str, + kind: io::ErrorKind, + error_code: Option<&str>, + error_description: Option<&str>, +) -> HandledRequest { let mut headers = Vec::new(); - if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..]) - { + if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) { headers.push(header); } + let body = render_login_error_page(message, error_code, error_description); HandledRequest::ResponseAndExit { headers, - body: message.as_bytes().to_vec(), - result: Err(io::Error::new( - io::ErrorKind::PermissionDenied, - message.to_string(), - )), + body, + result: Err(io::Error::new(kind, message.to_string())), } } +/// Returns true when the OAuth callback represents a missing Codex entitlement. +fn is_missing_codex_entitlement_error(error_code: &str, error_description: Option<&str>) -> bool { + error_code == "access_denied" + && error_description.is_some_and(|description| { + description + .to_ascii_lowercase() + .contains("missing_codex_entitlement") + }) +} + +/// Converts OAuth callback errors into a user-facing message. +fn oauth_callback_error_message(error_code: &str, error_description: Option<&str>) -> String { + if is_missing_codex_entitlement_error(error_code, error_description) { + return "Codex is not enabled for your workspace. Contact your workspace administrator to request access to Codex.".to_string(); + } + + if let Some(description) = error_description + && !description.trim().is_empty() + { + return format!("Sign-in failed: {description}"); + } + + format!("Sign-in failed: {error_code}") +} + +/// Extracts a readable error from token endpoint responses. +fn parse_token_endpoint_error(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return "unknown error".to_string(); + } + + let parsed = serde_json::from_str::(trimmed).ok(); + if let Some(json) = parsed { + if let Some(description) = json.get("error_description").and_then(JsonValue::as_str) + && !description.trim().is_empty() + { + return description.to_string(); + } + if let Some(error_obj) = json.get("error") + && let Some(message) = error_obj.get("message").and_then(JsonValue::as_str) + && !message.trim().is_empty() + { + return message.to_string(); + } + if let Some(error_code) = json.get("error").and_then(JsonValue::as_str) + && !error_code.trim().is_empty() + { + return error_code.to_string(); + } + } + + trimmed.to_string() +} + +/// Renders the branded error page used by callback failures. +fn render_login_error_page( + message: &str, + error_code: Option<&str>, + error_description: Option<&str>, +) -> Vec { + let template = include_str!("assets/error.html"); + let code = error_code.unwrap_or("unknown_error"); + let (title, display_message, display_description, help_text) = + if is_missing_codex_entitlement_error(code, error_description) { + ( + "You do not have access to Codex".to_string(), + "This account is not currently authorized to use Codex in this workspace." + .to_string(), + "Contact your workspace administrator to request access to Codex.".to_string(), + "Contact your workspace administrator to get access to Codex, then return to Codex and try again." + .to_string(), + ) + } else { + ( + "Sign-in could not be completed".to_string(), + message.to_string(), + error_description.unwrap_or(message).to_string(), + "Return to Codex to retry, switch accounts, or contact your workspace admin if access is restricted." + .to_string(), + ) + }; + template + .replace("__ERROR_TITLE__", &html_escape(&title)) + .replace("__ERROR_MESSAGE__", &html_escape(&display_message)) + .replace("__ERROR_CODE__", &html_escape(code)) + .replace("__ERROR_DESCRIPTION__", &html_escape(&display_description)) + .replace("__ERROR_HELP__", &html_escape(&help_text)) + .into_bytes() +} + +/// Escapes error strings before inserting them into HTML. +fn html_escape(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +/// Exchanges an authenticated ID token for an API-key style access token. pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 73cb8bd420..cdd4019f77 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -255,6 +255,152 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { Ok(()) } +#[tokio::test] +async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-entitlement".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: None, + }; + let server = run_login_server(opts)?; + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!( + "http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=missing_codex_entitlement" + ); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("You do not have access to Codex"), + "error body should clearly explain the Codex access denial" + ); + assert!( + body.contains("Contact your workspace administrator"), + "error body should tell the user how to get access" + ); + assert!( + body.contains("access_denied"), + "error body should still include the oauth error code" + ); + assert!( + !body.contains("missing_codex_entitlement"), + "known entitlement errors should be mapped to user-facing copy" + ); + + let result = server.block_until_done().await; + assert!(result.is_err(), "login should fail for access_denied"); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!( + err.to_string() + .contains("Contact your workspace administrator"), + "terminal error should also tell the user what to do next" + ); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when oauth callback is denied" + ); + + Ok(()) +} + +#[tokio::test] +async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-generic-denial".to_string(); + + let opts = ServerOptions { + codex_home: codex_home.clone(), + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state.clone()), + forced_chatgpt_workspace_id: None, + }; + let server = run_login_server(opts)?; + let login_port = server.actual_port; + + let client = reqwest::Client::new(); + let url = format!( + "http://127.0.0.1:{login_port}/auth/callback?state={state}&error=access_denied&error_description=some_other_reason" + ); + let resp = client.get(&url).send().await?; + assert!(resp.status().is_success()); + let body = resp.text().await?; + assert!( + body.contains("Sign-in could not be completed"), + "generic oauth denial should use the generic error page title" + ); + assert!( + body.contains("Sign-in failed: some_other_reason"), + "generic oauth denial should preserve the oauth error details" + ); + assert!( + body.contains("Return to Codex to retry"), + "generic oauth denial should keep the generic help text" + ); + assert!( + body.contains("access_denied"), + "generic oauth denial should include the oauth error code" + ); + assert!( + body.contains("some_other_reason"), + "generic oauth denial should include the oauth error description" + ); + assert!( + !body.contains("You do not have access to Codex"), + "generic oauth denial should not show the entitlement-specific title" + ); + assert!( + !body.contains("get access to Codex"), + "generic oauth denial should not show the entitlement-specific admin guidance" + ); + + let result = server.block_until_done().await; + assert!(result.is_err(), "login should fail for access_denied"); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert!( + err.to_string() + .contains("Sign-in failed: some_other_reason"), + "terminal error should preserve generic oauth details" + ); + + let auth_path = codex_home.join("auth.json"); + assert!( + !auth_path.exists(), + "auth.json should not be written when oauth callback is denied" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); From 67e802e26b7526d9aeceebb5faf748c04f13388d Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 20 Feb 2026 11:51:17 -0800 Subject: [PATCH 24/39] ci(bazel): install Node from node-version.txt in remote image (#12205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Install Node in the Bazel remote execution image using the version pinned in `codex-rs/node-version.txt`. ## Why `js_repl` tests run under Bazel remote execution and require a modern Node runtime. Runner-level `setup-node` does not guarantee Node is available (or recent enough) inside the remote worker container. ## What changed - Updated `.github/workflows/Dockerfile.bazel` to install Node from official tarballs at image build time. - Added `xz-utils` for extracting `.tar.xz` archives. - Copied `codex-rs/node-version.txt` into the image build context and used it as the single source of truth for Node version. - Added architecture mapping for multi-arch builds: - `amd64 -> x64` - `arm64 -> arm64` - Verified install during image build with: - `node --version` - `npm --version` ## Impact - Bazel remote workers should now have the required Node version available for `js_repl` tests. - Keeps Node version synchronized with repo policy via `codex-rs/node-version.txt`. ## Testing - Verified Dockerfile changes and build steps locally (build-time commands are deterministic and fail fast on unsupported arch/version fetch issues). ## Follow-up - Rebuild and publish the Bazel runner image for both `linux/amd64` and `linux/arm64`. - Update image digests in `rbe.bzl` to roll out this runtime update in CI. #### [git stack](https://github.com/magus/git-stack-cli) - ✅ `1` https://github.com/openai/codex/pull/12300 - ✅ `2` https://github.com/openai/codex/pull/12275 - 👉 `3` https://github.com/openai/codex/pull/12205 - ⏳ `4` https://github.com/openai/codex/pull/12185 - ⏳ `5` https://github.com/openai/codex/pull/10673 --- .github/workflows/Dockerfile.bazel | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Dockerfile.bazel b/.github/workflows/Dockerfile.bazel index 51c199dcc3..4f85409f94 100644 --- a/.github/workflows/Dockerfile.bazel +++ b/.github/workflows/Dockerfile.bazel @@ -8,9 +8,25 @@ FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl git python3 ca-certificates && \ + curl git python3 ca-certificates xz-utils && \ rm -rf /var/lib/apt/lists/* +COPY codex-rs/node-version.txt /tmp/node-version.txt + +RUN set -eux; \ + node_arch="$(dpkg --print-architecture)"; \ + case "${node_arch}" in \ + amd64) node_dist_arch="x64" ;; \ + arm64) node_dist_arch="arm64" ;; \ + *) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \ + esac; \ + node_version="$(tr -d '[:space:]' Date: Fri, 20 Feb 2026 20:02:24 +0000 Subject: [PATCH 25/39] feat: add config `allow_login_shell` (#12312) --- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/codex.rs | 3 + codex-rs/core/src/config/mod.rs | 46 ++++++++++ codex-rs/core/src/memories/usage.rs | 22 +++-- codex-rs/core/src/skills/loader.rs | 4 + codex-rs/core/src/skills/permissions.rs | 4 + codex-rs/core/src/tools/handlers/shell.rs | 87 ++++++++++++++++--- .../core/src/tools/handlers/unified_exec.rs | 69 ++++++++++++--- codex-rs/core/src/tools/spec.rs | 61 ++++++++----- 9 files changed, 244 insertions(+), 56 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 732f9590f7..97ce03cadf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1392,6 +1392,10 @@ ], "description": "Agent-related settings (thread limits, etc.)." }, + "allow_login_shell": { + "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", + "type": "boolean" + }, "analytics": { "allOf": [ { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b5614cced5..2d5d283d6a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -609,6 +609,7 @@ impl TurnContext { features: &features, web_search_mode: self.tools_config.web_search_mode, }) + .with_allow_login_shell(self.tools_config.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); Self { @@ -941,6 +942,7 @@ impl Session { features: &per_turn_config.features, web_search_mode: Some(per_turn_config.web_search_mode.value()), }) + .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -4122,6 +4124,7 @@ async fn spawn_review_thread( features: &review_features, web_search_mode: Some(review_web_search_mode), }) + .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); let review_prompt = resolved.prompt.clone(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1675067c7a..3b8e0382e8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -139,6 +139,15 @@ pub struct Permissions { pub sandbox_policy: Constrained, /// Effective network configuration applied to all spawned processes. pub network: Option, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: bool, /// Policy used to build process environments for shell/unified exec. pub shell_environment_policy: ShellEnvironmentPolicy, /// Effective Windows sandbox mode derived from `[windows].sandbox` or @@ -952,6 +961,16 @@ pub struct ConfigToml { #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + /// Sandbox mode to use. pub sandbox_mode: Option, @@ -1710,6 +1729,7 @@ impl Config { .clone(); let shell_environment_policy = cfg.shell_environment_policy.into(); + let allow_login_shell = cfg.allow_login_shell.unwrap_or(true); let history = cfg.history.unwrap_or_default(); @@ -1950,6 +1970,7 @@ impl Config { approval_policy: constrained_approval_policy.value, sandbox_policy: constrained_sandbox_policy.value, network, + allow_login_shell, shell_environment_policy, windows_sandbox_mode, macos_seatbelt_profile_extensions: None, @@ -4519,6 +4540,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4637,6 +4659,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4753,6 +4776,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -4855,6 +4879,7 @@ model_verbosity = "high" approval_policy: Constrained::allow_any(AskForApproval::OnFailure), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -5429,6 +5454,27 @@ mcp_oauth_callback_port = 5678 Ok(()) } + #[test] + fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg: ConfigToml = toml::from_str( + r#" +model = "gpt-5.1" +allow_login_shell = false +"#, + ) + .expect("TOML deserialization should succeed for allow_login_shell"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert!(!config.permissions.allow_login_shell); + Ok(()) + } + #[test] fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/memories/usage.rs b/codex-rs/core/src/memories/usage.rs index dbdcd25735..ff82d6e164 100644 --- a/codex-rs/core/src/memories/usage.rs +++ b/codex-rs/core/src/memories/usage.rs @@ -84,22 +84,28 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() .map(|params| { + if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { + return (Vec::new(), invocation.turn.resolve_path(params.workdir)); + } + let use_login_shell = params + .login + .unwrap_or(invocation.turn.tools_config.allow_login_shell); let command = invocation .session .user_shell() - .derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + .derive_exec_args(¶ms.command, use_login_shell); (command, invocation.turn.resolve_path(params.workdir)) }), "exec_command" => serde_json::from_str::(arguments) .ok() - .map(|params| { - ( - crate::tools::handlers::unified_exec::get_command( - ¶ms, - invocation.session.user_shell(), - ), - invocation.turn.resolve_path(params.workdir), + .and_then(|params| { + let command = crate::tools::handlers::unified_exec::get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, ) + .ok()?; + Some((command, invocation.turn.resolve_path(params.workdir))) }), _ => None, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index e00bd42976..3c497bfcf8 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1372,6 +1372,7 @@ permissions: } ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -1408,6 +1409,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: Some( @@ -1421,6 +1423,7 @@ permissions: {} crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, @@ -1515,6 +1518,7 @@ permissions: crate::protocol::SandboxPolicy::new_read_only_policy(), ), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions: None, diff --git a/codex-rs/core/src/skills/permissions.rs b/codex-rs/core/src/skills/permissions.rs index 1cf18e1052..24f3c9444c 100644 --- a/codex-rs/core/src/skills/permissions.rs +++ b/codex-rs/core/src/skills/permissions.rs @@ -112,6 +112,7 @@ pub(crate) fn compile_permission_profile( approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(sandbox_policy), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, macos_seatbelt_profile_extensions, @@ -347,6 +348,7 @@ mod tests { exclude_slash_tmp: false, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -391,6 +393,7 @@ mod tests { approval_policy: Constrained::allow_any(AskForApproval::Never), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] @@ -439,6 +442,7 @@ mod tests { }, }), network: None, + allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 8ca6de0d80..f040b4ba64 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -63,8 +63,20 @@ impl ShellHandler { } impl ShellCommandHandler { - fn base_command(shell: &Shell, command: &str, login: Option) -> Vec { - let use_login_shell = login.unwrap_or(true); + fn resolve_use_login_shell( + login: Option, + allow_login_shell: bool, + ) -> Result { + if !allow_login_shell && login == Some(true) { + return Err(FunctionCallError::RespondToModel( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + )); + } + + Ok(login.unwrap_or(allow_login_shell)) + } + + fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { shell.derive_exec_args(command, use_login_shell) } @@ -73,11 +85,13 @@ impl ShellCommandHandler { session: &crate::codex::Session, turn_context: &TurnContext, thread_id: ThreadId, - ) -> ExecParams { + allow_login_shell: bool, + ) -> Result { let shell = session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - ExecParams { + Ok(ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), @@ -87,7 +101,7 @@ impl ShellCommandHandler { windows_sandbox_level: turn_context.windows_sandbox_level, justification: params.justification.clone(), arg0: None, - } + }) } } @@ -183,8 +197,15 @@ impl ToolHandler for ShellCommandHandler { serde_json::from_str::(arguments) .map(|params| { + let use_login_shell = match Self::resolve_use_login_shell( + params.login, + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(use_login_shell) => use_login_shell, + Err(_) => return true, + }; let shell = invocation.session.user_shell(); - let command = Self::base_command(shell.as_ref(), ¶ms.command, params.login); + let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); !is_known_safe_command(&command) }) .unwrap_or(true) @@ -213,7 +234,8 @@ impl ToolHandler for ShellCommandHandler { session.as_ref(), turn.as_ref(), session.conversation_id, - ); + turn.tools_config.allow_login_shell, + )?; ShellHandler::run_exec_like(RunExecLikeArgs { tool_name, exec_params, @@ -439,7 +461,9 @@ mod tests { &session, &turn_context, session.conversation_id, - ); + true, + ) + .expect("login shells should be allowed"); // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. assert_eq!(exec_params.command, expected_command); @@ -464,18 +488,57 @@ mod tests { shell_snapshot, }; - let login_command = - ShellCommandHandler::base_command(&shell, "echo login shell", Some(true)); + let login_command = ShellCommandHandler::base_command(&shell, "echo login shell", true); assert_eq!( login_command, shell.derive_exec_args("echo login shell", true) ); let non_login_command = - ShellCommandHandler::base_command(&shell, "echo non login shell", Some(false)); + ShellCommandHandler::base_command(&shell, "echo non login shell", false); assert_eq!( non_login_command, shell.derive_exec_args("echo non login shell", false) ); } + + #[tokio::test] + async fn shell_command_handler_defaults_to_non_login_when_disallowed() { + let (session, turn_context) = make_session_and_context().await; + let params = ShellCommandToolCallParams { + command: "echo hello".to_string(), + workdir: None, + login: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + justification: None, + }; + + let exec_params = ShellCommandHandler::to_exec_params( + ¶ms, + &session, + &turn_context, + session.conversation_id, + false, + ) + .expect("non-login shells should still be allowed"); + + assert_eq!( + exec_params.command, + session.user_shell().derive_exec_args("echo hello", false) + ); + } + + #[test] + fn shell_command_handler_rejects_login_when_disallowed() { + let err = ShellCommandHandler::resolve_use_login_shell(Some(true), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.to_string() + .contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 1bad6fa638..62da64a877 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -32,8 +32,8 @@ pub(crate) struct ExecCommandArgs { pub(crate) workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_tty")] tty: bool, #[serde(default = "default_exec_yield_time_ms")] @@ -68,10 +68,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - fn default_tty() -> bool { false } @@ -98,7 +94,14 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms, invocation.session.user_shell()); + let command = match get_command( + ¶ms, + invocation.session.user_shell(), + invocation.turn.tools_config.allow_login_shell, + ) { + Ok(command) => command, + Err(_) => return true, + }; !is_known_safe_command(&command) } @@ -129,7 +132,12 @@ impl ToolHandler for UnifiedExecHandler { "exec_command" => { let args: ExecCommandArgs = parse_arguments(&arguments)?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args, session.user_shell()); + let command = get_command( + &args, + session.user_shell(), + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; let ExecCommandArgs { workdir, @@ -238,7 +246,11 @@ impl ToolHandler for UnifiedExecHandler { } } -pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { +pub(crate) fn get_command( + args: &ExecCommandArgs, + session_shell: Arc, + allow_login_shell: bool, +) -> Result, String> { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); @@ -246,8 +258,17 @@ pub(crate) fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); + let use_login_shell = match args.login { + Some(true) if !allow_login_shell => { + return Err( + "login shell is disabled by config; omit `login` or set it to false.".to_string(), + ); + } + Some(use_login_shell) => use_login_shell, + None => allow_login_shell, + }; - shell.derive_exec_args(&args.cmd, args.login) + Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -283,6 +304,7 @@ fn format_response(response: &UnifiedExecResponse) -> String { mod tests { use super::*; use crate::shell::default_user_shell; + use pretty_assertions::assert_eq; use std::sync::Arc; #[test] @@ -293,7 +315,8 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -308,7 +331,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -328,7 +352,8 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) @@ -342,9 +367,25 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args, Arc::new(default_user_shell())); + let command = + get_command(&args, Arc::new(default_user_shell()), true).map_err(anyhow::Error::msg)?; assert_eq!(command[2], "echo hello"); Ok(()) } + + #[test] + fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<()> { + let json = r#"{"cmd": "echo hello", "login": true}"#; + + let args: ExecCommandArgs = parse_arguments(json)?; + let err = get_command(&args, Arc::new(default_user_shell()), false) + .expect_err("explicit login should be rejected"); + + assert!( + err.contains("login shell is disabled by config"), + "unexpected error: {err}" + ); + Ok(()) + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fe9c7ee8a5..7bd1fc9788 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -35,6 +35,7 @@ const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, + pub allow_login_shell: bool, pub apply_patch_tool_type: Option, pub web_search_mode: Option, pub agent_roles: BTreeMap, @@ -96,6 +97,7 @@ impl ToolsConfig { Self { shell_type, + allow_login_shell: true, apply_patch_tool_type, web_search_mode: *web_search_mode, agent_roles: BTreeMap::new(), @@ -112,6 +114,11 @@ impl ToolsConfig { self.agent_roles = agent_roles; self } + + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { + self.allow_login_shell = allow_login_shell; + self + } } /// Generic JSON‑Schema subset needed for our tool definitions @@ -211,7 +218,7 @@ fn create_approval_parameters() -> BTreeMap { properties } -fn create_exec_command_tool() -> ToolSpec { +fn create_exec_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "cmd".to_string(), @@ -234,14 +241,6 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell binary to launch. Defaults to the user's default shell.".to_string()), }, ), - ( - "login".to_string(), - JsonSchema::Boolean { - description: Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), - ), - }, - ), ( "tty".to_string(), JsonSchema::Boolean { @@ -269,6 +268,16 @@ fn create_exec_command_tool() -> ToolSpec { }, ), ]); + if allow_login_shell { + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + ), + }, + ); + } properties.extend(create_approval_parameters()); ToolSpec::Function(ResponsesApiTool { @@ -385,7 +394,7 @@ Examples of valid command strings: }) } -fn create_shell_command_tool() -> ToolSpec { +fn create_shell_command_tool(allow_login_shell: bool) -> ToolSpec { let mut properties = BTreeMap::from([ ( "command".to_string(), @@ -402,6 +411,14 @@ fn create_shell_command_tool() -> ToolSpec { }, ), ( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ), + ]); + if allow_login_shell { + properties.insert( "login".to_string(), JsonSchema::Boolean { description: Some( @@ -409,14 +426,8 @@ fn create_shell_command_tool() -> ToolSpec { .to_string(), ), }, - ), - ( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some("The timeout for the command in milliseconds".to_string()), - }, - ), - ]); + ); + } properties.extend(create_approval_parameters()); let description = if cfg!(windows) { @@ -1462,7 +1473,10 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(ToolSpec::LocalShell {}, true); } ConfigShellToolType::UnifiedExec => { - builder.push_spec_with_parallel_support(create_exec_command_tool(), true); + builder.push_spec_with_parallel_support( + create_exec_command_tool(config.allow_login_shell), + true, + ); builder.push_spec(create_write_stdin_tool()); builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); @@ -1471,7 +1485,10 @@ pub(crate) fn build_specs( // Do nothing. } ConfigShellToolType::ShellCommand => { - builder.push_spec_with_parallel_support(create_shell_command_tool(), true); + builder.push_spec_with_parallel_support( + create_shell_command_tool(config.allow_login_shell), + true, + ); } } @@ -1816,7 +1833,7 @@ mod tests { // Build expected from the same helpers used by the builder. let mut expected: BTreeMap = BTreeMap::from([]); for spec in [ - create_exec_command_tool(), + create_exec_command_tool(true), create_write_stdin_tool(), PLAN_TOOL.clone(), create_request_user_input_tool(), @@ -2790,7 +2807,7 @@ Examples of valid command strings: #[test] fn test_shell_command_tool() { - let tool = super::create_shell_command_tool(); + let tool = super::create_shell_command_tool(true); let ToolSpec::Function(ResponsesApiTool { description, name, .. }) = &tool From aa121a115e257cff4ace1075e1a0411d48f22a59 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 12:05:21 -0800 Subject: [PATCH 26/39] [apps] Implement apps configs. (#12086) - [x] Implement apps configs. --- .../codex_app_server_protocol.schemas.json | 101 +++- .../schema/json/v2/ConfigReadResponse.json | 103 +++- ...ppDisabledReason.ts => AppToolApproval.ts} | 2 +- .../schema/typescript/v2/AppToolsConfig.ts | 6 + .../schema/typescript/v2/AppsConfig.ts | 6 +- .../schema/typescript/v2/AppsDefaultConfig.ts | 5 + .../schema/typescript/v2/index.ts | 4 +- .../app-server-protocol/src/protocol/v2.rs | 44 +- .../app-server/tests/suite/v2/config_rpc.rs | 23 +- codex-rs/core/config.schema.json | 89 +++- codex-rs/core/src/codex.rs | 19 +- codex-rs/core/src/config/service.rs | 15 +- codex-rs/core/src/config/types.rs | 84 ++- codex-rs/core/src/connectors.rs | 488 +++++++++++++++++- codex-rs/core/src/mcp_tool_call.rs | 159 ++++-- .../src/tools/handlers/search_tool_bm25.rs | 1 + 16 files changed, 1043 insertions(+), 106 deletions(-) rename codex-rs/app-server-protocol/schema/typescript/v2/{AppDisabledReason.ts => AppToolApproval.ts} (71%) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.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 5a840f0bde..7a01b04b51 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 @@ -10665,30 +10665,51 @@ }, "AppConfig": { "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "anyOf": [ { - "$ref": "#/definitions/v2/AppDisabledReason" + "$ref": "#/definitions/v2/AppToolApproval" }, { "type": "null" } ] }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, "enabled": { "default": true, "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolsConfig" + }, + { + "type": "null" + } + ] } }, "type": "object" }, - "AppDisabledReason": { - "enum": [ - "unknown", - "user" - ], - "type": "string" - }, "AppInfo": { "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", "properties": { @@ -10914,7 +10935,69 @@ ], "type": "object" }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, "type": "object" }, "AppsListParams": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 293fe7dc31..4511515d17 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -19,10 +19,65 @@ }, "AppConfig": { "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "anyOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" }, { "type": "null" @@ -30,20 +85,48 @@ ] }, "enabled": { - "default": true, - "type": "boolean" + "type": [ + "boolean", + "null" + ] } }, "type": "object" }, - "AppDisabledReason": { - "enum": [ - "unknown", - "user" - ], - "type": "string" + "AppToolsConfig": { + "type": "object" }, "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts similarity index 71% rename from codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts index bc44835a76..e92cd8e28b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AppDisabledReason = "unknown" | "user"; +export type AppToolApproval = "auto" | "prompt" | "approve"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.ts new file mode 100644 index 0000000000..16a1c22c6d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolsConfig.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 { AppToolApproval } from "./AppToolApproval"; + +export type AppToolsConfig = { [key in string]?: { enabled: boolean | null, approval_mode: AppToolApproval | null, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ae3e455201..b22997de97 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -1,6 +1,8 @@ // 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 { AppDisabledReason } from "./AppDisabledReason"; +import type { AppToolApproval } from "./AppToolApproval"; +import type { AppToolsConfig } from "./AppToolsConfig"; +import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.ts new file mode 100644 index 0000000000..e73386027e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsDefaultConfig.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 AppsDefaultConfig = { enabled: boolean, destructive_enabled: boolean, open_world_enabled: boolean, }; 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 db94051fa6..c2f8494785 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -7,13 +7,15 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; export type { AppBranding } from "./AppBranding"; -export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; +export type { AppToolApproval } from "./AppToolApproval"; +export type { AppToolsConfig } from "./AppToolsConfig"; export type { AppsConfig } from "./AppsConfig"; +export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2641d7c2a3..103b5f56a0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -398,12 +398,41 @@ pub struct AnalyticsConfig { pub additional: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] -pub enum AppDisabledReason { - Unknown, - User, +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -412,15 +441,20 @@ pub enum AppDisabledReason { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - pub disabled_reason: Option, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, #[serde(default, flatten)] - #[schemars(with = "HashMap")] pub apps: HashMap, } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index de4c51cde4..cd74710876 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -4,7 +4,7 @@ use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use app_test_support::to_response; use codex_app_server_protocol::AppConfig; -use codex_app_server_protocol::AppDisabledReason; +use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -156,7 +156,8 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps.app1] enabled = false -disabled_reason = "user" +destructive_enabled = false +default_tools_approval_mode = "prompt" "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -185,11 +186,16 @@ disabled_reason = "user" assert_eq!( config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: Some(false), + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) @@ -202,7 +208,16 @@ disabled_reason = "user" ); assert_eq!( origins - .get("apps.app1.disabled_reason") + .get("apps.app1.destructive_enabled") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.default_tools_approval_mode") .expect("origin") .name, ConfigLayerSource::User { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 97ce03cadf..e73a71bb30 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -85,34 +85,111 @@ "additionalProperties": false, "description": "Config values for a single app/connector.", "properties": { - "disabled_reason": { + "default_tools_approval_mode": { "allOf": [ { - "$ref": "#/definitions/AppDisabledReason" + "$ref": "#/definitions/AppToolApproval" } ], - "description": "Reason this app was disabled." + "description": "Approval mode for tools in this app unless a tool override exists." + }, + "default_tools_enabled": { + "description": "Whether tools are enabled by default for this app.", + "type": "boolean" + }, + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed for this app.", + "type": "boolean" }, "enabled": { "default": true, "description": "When `false`, Codex does not surface this app.", "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed for this app.", + "type": "boolean" + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + } + ], + "description": "Per-tool settings for this app." } }, "type": "object" }, - "AppDisabledReason": { + "AppToolApproval": { "enum": [ - "unknown", - "user" + "auto", + "prompt", + "approve" ], "type": "string" }, + "AppToolConfig": { + "additionalProperties": false, + "description": "Per-tool settings for a single app tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + }, + "enabled": { + "description": "Whether this tool is enabled. `Some(true)` explicitly allows this tool.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppToolsConfig": { + "additionalProperties": { + "$ref": "#/definitions/AppToolConfig" + }, + "description": "Tool settings for a single app.", + "type": "object" + }, "AppsConfigToml": { "additionalProperties": { "$ref": "#/definitions/AppConfig" }, "description": "App/connector settings loaded from `config.toml`.", + "properties": { + "_default": { + "allOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + } + ], + "description": "Default settings for all apps." + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "additionalProperties": false, + "description": "Default settings that apply to all apps.", + "properties": { + "destructive_enabled": { + "description": "Whether tools with `destructive_hint = true` are allowed by default.", + "type": "boolean" + }, + "enabled": { + "default": true, + "description": "When `false`, apps are disabled unless overridden by per-app settings.", + "type": "boolean" + }, + "open_world_enabled": { + "description": "Whether tools with `open_world_hint = true` are allowed by default.", + "type": "boolean" + } + }, "type": "object" }, "AskForApproval": { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2d5d283d6a..fcddf4e83e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4834,6 +4834,7 @@ fn connector_inserted_in_messages( fn filter_codex_apps_mcp_tools( mcp_tools: &HashMap, connectors: &[connectors::AppInfo], + config: &Config, ) -> HashMap { let allowed: HashSet<&str> = connectors .iter() @@ -4849,7 +4850,7 @@ fn filter_codex_apps_mcp_tools( let Some(connector_id) = codex_apps_connector_id(tool) else { return false; }; - allowed.contains(connector_id) + allowed.contains(connector_id) && connectors::codex_app_tool_is_enabled(config, tool) }) .map(|(name, tool)| (name.clone(), tool.clone())) .collect() @@ -5023,9 +5024,9 @@ async fn built_tools( None }; - let app_tools = connectors - .as_ref() - .map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors)); + let app_tools = connectors.as_ref().map(|connectors| { + filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config) + }); if let Some(connectors) = connectors.as_ref() { let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { @@ -5050,7 +5051,8 @@ async fn built_tools( explicitly_enabled.as_ref(), )); - mcp_tools = selected_mcp_tools; + mcp_tools = + connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config); } Ok(Arc::new(ToolRouter::from_config( @@ -6001,7 +6003,7 @@ mod tests { let config_toml_path = codex_home.join(CONFIG_TOML_FILE); std::fs::write( &config_toml_path, - "[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n", + "[apps.calendar]\nenabled = false\ndestructive_enabled = false\n", ) .expect("write user config"); @@ -6023,10 +6025,7 @@ mod tests { .expect("calendar app config exists"); assert!(!app.enabled); - assert_eq!( - app.disabled_reason, - Some(crate::config::types::AppDisabledReason::User) - ); + assert_eq!(app.destructive_enabled, Some(false)); } #[test] diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 02a9da23fd..1033db728d 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -701,7 +701,7 @@ mod tests { use super::*; use anyhow::Result; use codex_app_server_protocol::AppConfig; - use codex_app_server_protocol::AppDisabledReason; + use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; @@ -827,13 +827,13 @@ personality = true service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "apps.app1.disabled_reason".to_string(), - value: serde_json::json!("user"), + key_path: "apps.app1.default_tools_approval_mode".to_string(), + value: serde_json::json!("prompt"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await - .expect("write apps.app1.disabled_reason succeeds"); + .expect("write apps.app1.default_tools_approval_mode succeeds"); let read = service .read(ConfigReadParams { @@ -846,11 +846,16 @@ personality = true assert_eq!( read.config.apps, Some(AppsConfig { + default: None, apps: std::collections::HashMap::from([( "app1".to_string(), AppConfig { enabled: false, - disabled_reason: Some(AppDisabledReason::User), + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: None, }, )]), }) diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index ca90414c27..2e3573f770 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -425,20 +425,58 @@ impl From for MemoriesConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum AppDisabledReason { - Unknown, - User, +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Approve, } -impl fmt::Display for AppDisabledReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AppDisabledReason::Unknown => write!(f, "unknown"), - AppDisabledReason::User => write!(f, "user"), - } - } +/// Default settings that apply to all apps. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + /// When `false`, apps are disabled unless overridden by per-app settings. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Whether tools with `destructive_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + + /// Whether tools with `open_world_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, +} + +/// Per-tool settings for a single app tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// Whether this tool is enabled. `Some(true)` explicitly allows this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +/// Tool settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + /// Per-tool overrides keyed by tool name (for example `repos/list`). + #[serde(default, flatten)] + pub tools: HashMap, } /// Config values for a single app/connector. @@ -449,15 +487,35 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, - /// Reason this app was disabled. + /// Whether tools with `destructive_hint = true` are allowed for this app. #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, + pub destructive_enabled: Option, + + /// Whether tools with `open_world_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + + /// Approval mode for tools in this app unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Whether tools are enabled by default for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + + /// Per-tool settings for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, } /// App/connector settings loaded from `config.toml`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AppsConfigToml { + /// Default settings for all apps. + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). #[serde(default, flatten)] pub apps: HashMap, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index a34477850d..33b003770e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -11,6 +11,7 @@ pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_protocol::protocol::SandboxPolicy; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use tracing::warn; @@ -18,6 +19,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; use crate::default_client::is_first_party_chat_originator; use crate::default_client::originator; @@ -32,6 +34,21 @@ use crate::token_data::TokenData; pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AppToolPolicy { + pub enabled: bool, + pub approval: AppToolApproval, +} + +impl Default for AppToolPolicy { + fn default() -> Self { + Self { + enabled: true, + approval: AppToolApproval::Auto, + } + } +} + #[derive(Clone, PartialEq, Eq)] struct AccessibleConnectorsCacheKey { chatgpt_base_url: String, @@ -337,15 +354,58 @@ pub fn merge_connectors( } pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { - let apps = read_apps_config(config).map(|apps_config| apps_config.apps); - for connector in &mut connectors { - if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) { - connector.is_enabled = app.enabled; + let apps_config = read_apps_config(config); + if let Some(apps_config) = apps_config.as_ref() { + for connector in &mut connectors { + connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str())); } } connectors } +pub(crate) fn app_tool_policy( + config: &Config, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let apps_config = read_apps_config(config); + app_tool_policy_from_apps_config( + apps_config.as_ref(), + connector_id, + tool_name, + tool_title, + annotations, + ) +} + +pub(crate) fn codex_app_tool_is_enabled( + config: &Config, + tool_info: &crate::mcp_connection_manager::ToolInfo, +) -> bool { + if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + + app_tool_policy( + config, + tool_info.connector_id.as_deref(), + &tool_info.tool_name, + tool_info.tool.title.as_deref(), + tool_info.tool.annotations.as_ref(), + ) + .enabled +} + +pub(crate) fn filter_codex_apps_tools_by_policy( + mut mcp_tools: HashMap, + config: &Config, +) -> HashMap { + mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info)); + mcp_tools +} + const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ "asdk_app_6938a94a61d881918ef32cb999ff937c", "connector_2b0a9009c9c64bf9933a3dae3f2b1254", @@ -394,6 +454,85 @@ fn read_apps_config(config: &Config) -> Option { AppsConfigToml::deserialize(apps_config).ok() } +fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool { + let default_enabled = apps_config + .default + .as_ref() + .map(|defaults| defaults.enabled) + .unwrap_or(true); + + connector_id + .and_then(|connector_id| apps_config.apps.get(connector_id)) + .map(|app| app.enabled) + .unwrap_or(default_enabled) +} + +fn app_tool_policy_from_apps_config( + apps_config: Option<&AppsConfigToml>, + connector_id: Option<&str>, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> AppToolPolicy { + let Some(apps_config) = apps_config else { + return AppToolPolicy::default(); + }; + + let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id)); + let tools = app.and_then(|app| app.tools.as_ref()); + let tool_config = tools.and_then(|tools| { + tools + .tools + .get(tool_name) + .or_else(|| tool_title.and_then(|title| tools.tools.get(title))) + }); + let approval = tool_config + .and_then(|tool| tool.approval_mode) + .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) + .unwrap_or(AppToolApproval::Auto); + + if !app_is_enabled(apps_config, connector_id) { + return AppToolPolicy { + enabled: false, + approval, + }; + } + + if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) { + return AppToolPolicy { enabled, approval }; + } + + if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) { + return AppToolPolicy { enabled, approval }; + } + + let app_defaults = apps_config.default.as_ref(); + let destructive_enabled = app + .and_then(|app| app.destructive_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.destructive_enabled) + .unwrap_or(true) + }); + let open_world_enabled = app + .and_then(|app| app.open_world_enabled) + .unwrap_or_else(|| { + app_defaults + .map(|defaults| defaults.open_world_enabled) + .unwrap_or(true) + }); + let destructive_hint = annotations + .and_then(|annotations| annotations.destructive_hint) + .unwrap_or(false); + let open_world_hint = annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(false); + let enabled = + (destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint); + + AppToolPolicy { enabled, approval } +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -472,8 +611,25 @@ fn format_connector_label(name: &str, _id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::types::AppConfig; + use crate::config::types::AppToolConfig; + use crate::config::types::AppToolsConfig; + use crate::config::types::AppsDefaultConfig; use pretty_assertions::assert_eq; + fn annotations( + destructive_hint: Option, + open_world_hint: Option, + ) -> ToolAnnotations { + ToolAnnotations { + destructive_hint, + idempotent_hint: None, + open_world_hint, + read_only_hint: None, + title: None, + } + } + fn app(id: &str) -> AppInfo { AppInfo { id: id.to_string(), @@ -491,6 +647,328 @@ mod tests { } } + #[test] + fn app_tool_policy_uses_global_defaults_for_destructive_hints() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: true, + destructive_enabled: false, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_is_enabled_uses_default_for_unconfigured_apps() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + assert!(!app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, None)); + } + + #[test] + fn app_is_enabled_prefers_per_app_override_over_default() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + assert!(app_is_enabled(&apps_config, Some("calendar"))); + assert!(!app_is_enabled(&apps_config, Some("drive"))); + } + + #[test] + fn app_tool_policy_honors_default_app_enabled_false() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::new(), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { + let apps_config = AppsConfigToml { + default: Some(AppsDefaultConfig { + enabled: false, + destructive_enabled: true, + open_world_enabled: true, + }), + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: None, + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: None, + default_tools_enabled: Some(true), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/create", + None, + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); + } + + #[test] + fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(true), + open_world_enabled: Some(true), + default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_enabled: Some(false), + tools: None, + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: false, + approval: AppToolApproval::Approve, + } + ); + } + + #[test] + fn app_tool_policy_uses_default_tools_approval_mode() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: None, + open_world_enabled: None, + default_tools_approval_mode: Some(AppToolApproval::Prompt), + default_tools_enabled: None, + tools: Some(AppToolsConfig { + tools: HashMap::new(), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "events/list", + None, + Some(&annotations(None, None)), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Prompt, + } + ); + } + + #[test] + fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { + let apps_config = AppsConfigToml { + default: None, + apps: HashMap::from([( + "calendar".to_string(), + AppConfig { + enabled: true, + destructive_enabled: Some(false), + open_world_enabled: Some(false), + default_tools_approval_mode: Some(AppToolApproval::Auto), + default_tools_enabled: Some(false), + tools: Some(AppToolsConfig { + tools: HashMap::from([( + "events/create".to_string(), + AppToolConfig { + enabled: Some(true), + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }; + + let policy = app_tool_policy_from_apps_config( + Some(&apps_config), + Some("calendar"), + "calendar_events/create", + Some("events/create"), + Some(&annotations(Some(true), Some(true))), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); + } + #[test] fn filter_disallowed_connectors_allows_non_disallowed_connectors() { let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); @@ -528,7 +1006,7 @@ mod tests { ); assert_eq!( filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),] + vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] ); } } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 5f7734118c..00327386b5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -7,6 +7,8 @@ use crate::analytics_client::AppInvocation; use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::types::AppToolApproval; +use crate::connectors; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; @@ -64,9 +66,51 @@ pub(crate) async fn handle_mcp_tool_call( arguments: arguments_value.clone(), }; - if let Some(decision) = - maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name) - .await + let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await; + let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME { + connectors::app_tool_policy( + &turn_context.config, + metadata + .as_ref() + .and_then(|metadata| metadata.connector_id.as_deref()), + &tool_name, + metadata + .as_ref() + .and_then(|metadata| metadata.tool_title.as_deref()), + metadata + .as_ref() + .and_then(|metadata| metadata.annotations.as_ref()), + ) + } else { + connectors::AppToolPolicy::default() + }; + + if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { + let result = notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context, + &call_id, + invocation, + "MCP tool call blocked by app configuration".to_string(), + ) + .await; + let status = if result.is_ok() { "ok" } else { "error" }; + turn_context + .otel_manager + .counter("codex.mcp.call", 1, &[("status", status)]); + return ResponseInputItem::McpToolCallOutput { call_id, result }; + } + + if let Some(decision) = maybe_request_mcp_tool_approval( + sess.as_ref(), + turn_context, + &call_id, + &server, + &tool_name, + metadata.as_ref(), + app_tool_policy.approval, + ) + .await { let result = match decision { McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => { @@ -267,7 +311,7 @@ enum McpToolApprovalDecision { } struct McpToolApprovalMetadata { - annotations: ToolAnnotations, + annotations: Option, connector_id: Option, connector_name: Option, tool_title: Option, @@ -292,21 +336,39 @@ async fn maybe_request_mcp_tool_approval( call_id: &str, server: &str, tool_name: &str, + metadata: Option<&McpToolApprovalMetadata>, + approval_mode: AppToolApproval, ) -> Option { - if is_full_access_mode(turn_context) { + if approval_mode == AppToolApproval::Approve { return None; } + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); + if approval_mode == AppToolApproval::Auto { + if is_full_access_mode(turn_context) { + return None; + } + if !annotations.is_some_and(requires_mcp_tool_approval) { + return None; + } + } - let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?; - if !requires_mcp_tool_approval(&metadata.annotations) { - return None; - } - let approval_key = McpToolApprovalKey { - server: server.to_string(), - connector_id: metadata.connector_id.clone(), - tool_name: tool_name.to_string(), + let approval_key = if approval_mode == AppToolApproval::Auto { + let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone()); + if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() { + None + } else { + Some(McpToolApprovalKey { + server: server.to_string(), + connector_id, + tool_name: tool_name.to_string(), + }) + } + } else { + None }; - if mcp_tool_approval_is_remembered(sess, &approval_key).await { + if let Some(key) = approval_key.as_ref() + && mcp_tool_approval_is_remembered(sess, key).await + { return Some(McpToolApprovalDecision::Accept); } @@ -315,10 +377,10 @@ async fn maybe_request_mcp_tool_approval( question_id.clone(), server, tool_name, - metadata.tool_title.as_deref(), - metadata.connector_name.as_deref(), - &metadata.annotations, - true, + metadata.and_then(|metadata| metadata.tool_title.as_deref()), + metadata.and_then(|metadata| metadata.connector_name.as_deref()), + annotations, + approval_key.is_some(), ); let args = RequestUserInputArgs { questions: vec![question], @@ -326,9 +388,14 @@ async fn maybe_request_mcp_tool_approval( let response = sess .request_user_input(turn_context, call_id.to_string(), args) .await; - let decision = parse_mcp_tool_approval_response(response, &question_id); - if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) { - remember_mcp_tool_approval(sess, approval_key).await; + let decision = normalize_approval_decision_for_mode( + parse_mcp_tool_approval_response(response, &question_id), + approval_mode, + ); + if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) + && let Some(key) = approval_key + { + remember_mcp_tool_approval(sess, key).await; } Some(decision) } @@ -356,15 +423,12 @@ async fn lookup_mcp_tool_metadata( tools.into_values().find_map(|tool_info| { if tool_info.server_name == server && tool_info.tool_name == tool_name { - tool_info - .tool - .annotations - .map(|annotations| McpToolApprovalMetadata { - annotations, - connector_id: tool_info.connector_id, - connector_name: tool_info.connector_name, - tool_title: tool_info.tool.title, - }) + Some(McpToolApprovalMetadata { + annotations: tool_info.tool.annotations, + connector_id: tool_info.connector_id, + connector_name: tool_info.connector_name, + tool_title: tool_info.tool.title, + }) } else { None } @@ -402,11 +466,12 @@ fn build_mcp_tool_approval_question( tool_name: &str, tool_title: Option<&str>, connector_name: Option<&str>, - annotations: &ToolAnnotations, + annotations: Option<&ToolAnnotations>, allow_remember_option: bool, ) -> RequestUserInputQuestion { - let destructive = annotations.destructive_hint == Some(true); - let open_world = annotations.open_world_hint == Some(true); + let destructive = + annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); + let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); let reason = match (destructive, open_world) { (true, true) => "may modify data and access external systems", (true, false) => "may modify or delete data", @@ -493,6 +558,19 @@ fn parse_mcp_tool_approval_response( } } +fn normalize_approval_decision_for_mode( + decision: McpToolApprovalDecision, + approval_mode: AppToolApproval, +) -> McpToolApprovalDecision { + if approval_mode == AppToolApproval::Prompt + && decision == McpToolApprovalDecision::AcceptAndRemember + { + McpToolApprovalDecision::Accept + } else { + decision + } +} + async fn mcp_tool_approval_is_remembered(sess: &Session, key: &McpToolApprovalKey) -> bool { let store = sess.services.tool_approvals.lock().await; matches!(store.get(key), Some(ReviewDecision::ApprovedForSession)) @@ -568,6 +646,17 @@ mod tests { assert_eq!(requires_mcp_tool_approval(&annotations), false); } + #[test] + fn prompt_mode_does_not_allow_session_remember() { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + AppToolApproval::Prompt, + ), + McpToolApprovalDecision::Accept + ); + } + #[test] fn custom_mcp_tool_question_mentions_server_name() { let question = build_mcp_tool_approval_question( @@ -576,7 +665,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); @@ -603,7 +692,7 @@ mod tests { "run_action", Some("Run Action"), None, - &annotations(Some(false), Some(true), None), + Some(&annotations(Some(false), Some(true), None)), true, ); diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index c5e267cb49..a4878b73a6 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -124,6 +124,7 @@ impl ToolHandler for SearchToolBm25Handler { &turn.config, ); let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors); + let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config); let mut entries: Vec = mcp_tools .into_iter() From d3cf8bd0fa6880b9a14ad8f9afb5a176adbc7291 Mon Sep 17 00:00:00 2001 From: colby-oai <228809017+colby-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:12:16 -0500 Subject: [PATCH 27/39] fix(core): require approval for destructive MCP tool calls (#12353) Summary - ensure destructive tool annotations short-circuit to require approval - simplify approval logic to only require read/write + open-world when destructive is false - update the unit test to cover the new destructive behavior Testing - Not run (not requested) --- codex-rs/core/src/mcp_tool_call.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 00327386b5..f194ee79a6 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -582,8 +582,11 @@ async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) { } fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - annotations.read_only_hint == Some(false) - && (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true)) + if annotations.destructive_hint == Some(true) { + return true; + } + + annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) } async fn notify_mcp_tool_call_skip( @@ -641,9 +644,9 @@ mod tests { } #[test] - fn approval_not_required_when_read_only_true() { + fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), false); + assert_eq!(requires_mcp_tool_approval(&annotations), true); } #[test] From 6b1091fc92b7bac7cd25bc4772081eff7ad8f9ac Mon Sep 17 00:00:00 2001 From: Max Johnson <162359438+maxj-oai@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:35:16 -0800 Subject: [PATCH 28/39] app-server: harden disconnect cleanup paths (#12218) Hardens codex-rs/app-server connection lifecycle and outbound routing for websocket clients. Fixes some FUD I was having - Added per-connection disconnect signaling (CancellationToken) for websocket transports. - Split websocket handling into independent inbound/outbound tasks coordinated by cancellation. - Changed outbound routing so websocket connections use non-blocking try_send; slow/full websocket writers are disconnected instead of stalling broadcast delivery. - Kept stdio behavior blocking-on-send (no forced disconnect) so local stdio clients are not dropped when queues are temporarily full. - Simplified outbound router flow by removing deferred pending_closed_connections handling. - Added guards to drop incoming response/notification/error messages from unknown connections. - Fixed listener teardown race in thread listener tasks using a listener_generation check so stale tasks do not clear newer listeners. Fixes https://linear.app/openai/issue/CODEX-4966/multiclient-handle-slow-notification-consumers ## Tests Added/updated transport tests covering: - broadcast does not block on a slow/full websocket connection - stdio connection waits instead of disconnecting on full queue I (maxj) have tested manually and will retest before landing --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 7 +- codex-rs/app-server/src/lib.rs | 100 ++--- codex-rs/app-server/src/thread_state.rs | 6 +- codex-rs/app-server/src/transport.rs | 344 ++++++++++++++++-- 6 files changed, 372 insertions(+), 87 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ccdfddb3ac..7818b253cf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1327,6 +1327,7 @@ dependencies = [ "time", "tokio", "tokio-tungstenite", + "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", "tracing-subscriber", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 302a65d2f6..7b90affe69 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -48,6 +48,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tokio-util = { workspace = true } tokio-tungstenite = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 24c89edaf3..29d8acdbc1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5811,7 +5811,7 @@ impl CodexMessageProcessor { api_version: ApiVersion, ) { let (cancel_tx, mut cancel_rx) = oneshot::channel(); - let mut listener_command_rx = { + let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return; @@ -5927,6 +5927,11 @@ impl CodexMessageProcessor { } } } + + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state.clear_listener(); + } }); } async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 6ff802c2a9..1bdbf97a13 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -10,7 +10,6 @@ use codex_core::config_loader::LoaderOverrides; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; -use std::collections::VecDeque; use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::PathBuf; @@ -42,6 +41,7 @@ use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use tracing::error; use tracing::info; @@ -92,6 +92,7 @@ enum OutboundControlEvent { Opened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, initialized: Arc, opted_out_notification_methods: Arc>>, }, @@ -403,61 +404,43 @@ pub async fn run_main_with_transport( } } - let transport_event_tx_for_outbound = transport_event_tx.clone(); let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); - let mut pending_closed_connections = VecDeque::::new(); loop { tokio::select! { - biased; - event = outbound_control_rx.recv() => { - let Some(event) = event else { - break; - }; - match event { - OutboundControlEvent::Opened { - connection_id, - writer, - initialized, - opted_out_notification_methods, - } => { - outbound_connections.insert( + biased; + event = outbound_control_rx.recv() => { + let Some(event) = event else { + break; + }; + match event { + OutboundControlEvent::Opened { connection_id, - OutboundConnectionState::new( - writer, - initialized, - opted_out_notification_methods, - ), - ); - } - OutboundControlEvent::Closed { connection_id } => { - outbound_connections.remove(&connection_id); + writer, + disconnect_sender, + initialized, + opted_out_notification_methods, + } => { + outbound_connections.insert( + connection_id, + OutboundConnectionState::new( + writer, + initialized, + opted_out_notification_methods, + disconnect_sender, + ), + ); + } + OutboundControlEvent::Closed { connection_id } => { + outbound_connections.remove(&connection_id); + } } } - } - envelope = outgoing_rx.recv() => { + envelope = outgoing_rx.recv() => { let Some(envelope) = envelope else { break; }; - let disconnected_connections = - route_outgoing_envelope(&mut outbound_connections, envelope).await; - pending_closed_connections.extend(disconnected_connections); - } - } - - while let Some(connection_id) = pending_closed_connections.front().copied() { - match transport_event_tx_for_outbound - .try_send(TransportEvent::ConnectionClosed { connection_id }) - { - Ok(()) => { - pending_closed_connections.pop_front(); - } - Err(mpsc::error::TrySendError::Full(_)) => { - break; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return; - } + route_outgoing_envelope(&mut outbound_connections, envelope).await; } } } @@ -491,7 +474,11 @@ pub async fn run_main_with_transport( break; }; match event { - TransportEvent::ConnectionOpened { connection_id, writer } => { + TransportEvent::ConnectionOpened { + connection_id, + writer, + disconnect_sender, + } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); @@ -499,6 +486,7 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, + disconnect_sender, initialized: Arc::clone(&outbound_initialized), opted_out_notification_methods: Arc::clone( &outbound_opted_out_notification_methods, @@ -518,6 +506,9 @@ pub async fn run_main_with_transport( ); } TransportEvent::ConnectionClosed { connection_id } => { + if connections.remove(&connection_id).is_none() { + continue; + } if outbound_control_tx .send(OutboundControlEvent::Closed { connection_id }) .await @@ -526,7 +517,6 @@ pub async fn run_main_with_transport( break; } processor.connection_closed(connection_id).await; - connections.remove(&connection_id); if shutdown_when_no_connections && connections.is_empty() { break; } @@ -535,7 +525,7 @@ pub async fn run_main_with_transport( match message { JSONRPCMessage::Request(request) => { let Some(connection_state) = connections.get_mut(&connection_id) else { - warn!("dropping request from unknown connection: {:?}", connection_id); + warn!("dropping request from unknown connection: {connection_id:?}"); continue; }; let was_initialized = connection_state.session.initialized; @@ -565,12 +555,24 @@ pub async fn run_main_with_transport( } } JSONRPCMessage::Response(response) => { + if !connections.contains_key(&connection_id) { + warn!("dropping response from unknown connection: {connection_id:?}"); + continue; + } processor.process_response(response).await; } JSONRPCMessage::Notification(notification) => { + if !connections.contains_key(&connection_id) { + warn!("dropping notification from unknown connection: {connection_id:?}"); + continue; + } processor.process_notification(notification).await; } JSONRPCMessage::Error(err) => { + if !connections.contains_key(&connection_id) { + warn!("dropping error from unknown connection: {connection_id:?}"); + continue; + } processor.process_error(err).await; } } diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 264c9a39d6..a9d35e0b7c 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -47,6 +47,7 @@ pub(crate) struct ThreadState { pub(crate) turn_summary: TurnSummary, pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, + pub(crate) listener_generation: u64, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -65,14 +66,15 @@ impl ThreadState { &mut self, cancel_tx: oneshot::Sender<()>, conversation: &Arc, - ) -> mpsc::UnboundedReceiver { + ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } + self.listener_generation = self.listener_generation.wrapping_add(1); let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); - listener_command_rx + (listener_command_rx, self.listener_generation) } pub(crate) fn clear_listener(&mut self) { diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 558d786137..80768620bb 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -32,6 +32,7 @@ use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -135,6 +136,7 @@ pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, writer: mpsc::Sender, + disconnect_sender: Option, }, ConnectionClosed { connection_id: ConnectionId, @@ -168,6 +170,7 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) opted_out_notification_methods: Arc>>, pub(crate) writer: mpsc::Sender, + disconnect_sender: Option, } impl OutboundConnectionState { @@ -175,11 +178,23 @@ impl OutboundConnectionState { writer: mpsc::Sender, initialized: Arc, opted_out_notification_methods: Arc>>, + disconnect_sender: Option, ) -> Self { Self { initialized, opted_out_notification_methods, writer, + disconnect_sender, + } + } + + fn can_disconnect(&self) -> bool { + self.disconnect_sender.is_some() + } + + fn request_disconnect(&self) { + if let Some(disconnect_sender) = &self.disconnect_sender { + disconnect_sender.cancel(); } } } @@ -195,6 +210,7 @@ pub(crate) async fn start_stdio_connection( .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: None, }) .await .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; @@ -299,12 +315,14 @@ async fn run_websocket_connection( } }; - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); + let disconnect_token = CancellationToken::new(); if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), }) .await .is_err() @@ -312,9 +330,62 @@ async fn run_websocket_connection( return; } - let (mut websocket_writer, mut websocket_reader) = websocket_stream.split(); + let (websocket_writer, websocket_reader) = websocket_stream.split(); + let (writer_control_tx, writer_control_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( + websocket_writer, + writer_rx, + writer_control_rx, + disconnect_token.clone(), + )); + let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( + websocket_reader, + transport_event_tx.clone(), + writer_tx_for_reader, + writer_control_tx, + connection_id, + disconnect_token.clone(), + )); + + tokio::select! { + _ = &mut outbound_task => { + disconnect_token.cancel(); + inbound_task.abort(); + } + _ = &mut inbound_task => { + disconnect_token.cancel(); + outbound_task.abort(); + } + } + + let _ = transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; +} + +async fn run_websocket_outbound_loop( + mut websocket_writer: futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + WebSocketMessage, + >, + mut writer_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, + disconnect_token: CancellationToken, +) { loop { tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + message = writer_control_rx.recv() => { + let Some(message) = message else { + break; + }; + if websocket_writer.send(message).await.is_err() { + break; + } + } outgoing_message = writer_rx.recv() => { let Some(outgoing_message) = outgoing_message else { break; @@ -326,6 +397,25 @@ async fn run_websocket_connection( break; } } + } + } +} + +async fn run_websocket_inbound_loop( + mut websocket_reader: futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream, + >, + transport_event_tx: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, + writer_control_tx: mpsc::Sender, + connection_id: ConnectionId, + disconnect_token: CancellationToken, +) { + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(WebSocketMessage::Text(text))) => { @@ -341,8 +431,13 @@ async fn run_websocket_connection( } } Some(Ok(WebSocketMessage::Ping(payload))) => { - if websocket_writer.send(WebSocketMessage::Pong(payload)).await.is_err() { - break; + match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } } } Some(Ok(WebSocketMessage::Pong(_))) => {} @@ -359,10 +454,6 @@ async fn run_websocket_connection( } } } - - let _ = transport_event_tx - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; } async fn forward_incoming_message( @@ -461,30 +552,61 @@ fn should_skip_notification_for_connection( } } +fn disconnect_connection( + connections: &mut HashMap, + connection_id: ConnectionId, +) -> bool { + if let Some(connection_state) = connections.remove(&connection_id) { + connection_state.request_disconnect(); + return true; + } + false +} + +async fn send_message_to_connection( + connections: &mut HashMap, + connection_id: ConnectionId, + message: OutgoingMessage, +) -> bool { + let Some(connection_state) = connections.get(&connection_id) else { + warn!("dropping message for disconnected connection: {connection_id:?}"); + return false; + }; + if should_skip_notification_for_connection(connection_state, &message) { + return false; + } + + let writer = connection_state.writer.clone(); + if connection_state.can_disconnect() { + match writer.try_send(message) { + Ok(()) => false, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "disconnecting slow connection after outbound queue filled: {connection_id:?}" + ); + disconnect_connection(connections, connection_id) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + disconnect_connection(connections, connection_id) + } + } + } else if writer.send(message).await.is_err() { + disconnect_connection(connections, connection_id) + } else { + false + } +} + pub(crate) async fn route_outgoing_envelope( connections: &mut HashMap, envelope: OutgoingEnvelope, -) -> Vec { - let mut disconnected = Vec::new(); +) { match envelope { OutgoingEnvelope::ToConnection { connection_id, message, } => { - let Some(connection_state) = connections.get(&connection_id) else { - warn!( - "dropping message for disconnected connection: {:?}", - connection_id - ); - return disconnected; - }; - if should_skip_notification_for_connection(connection_state, &message) { - return disconnected; - } - if connection_state.writer.send(message).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = send_message_to_connection(connections, connection_id, message).await; } OutgoingEnvelope::Broadcast { message } => { let target_connections: Vec = connections @@ -501,17 +623,11 @@ pub(crate) async fn route_outgoing_envelope( .collect(); for connection_id in target_connections { - let Some(connection_state) = connections.get(&connection_id) else { - continue; - }; - if connection_state.writer.send(message.clone()).await.is_err() { - connections.remove(&connection_id); - disconnected.push(connection_id); - } + let _ = + send_message_to_connection(connections, connection_id, message.clone()).await; } } } - disconnected } #[cfg(test)] @@ -520,6 +636,8 @@ mod tests { use crate::error_code::OVERLOADED_ERROR_CODE; use pretty_assertions::assert_eq; use serde_json::json; + use tokio::time::Duration; + use tokio::time::timeout; #[test] fn app_server_transport_parses_stdio_listen_url() { @@ -754,10 +872,15 @@ mod tests { let mut connections = HashMap::new(); connections.insert( connection_id, - OutboundConnectionState::new(writer_tx, initialized, opted_out_notification_methods), + OutboundConnectionState::new( + writer_tx, + initialized, + opted_out_notification_methods, + None, + ), ); - let disconnected = route_outgoing_envelope( + route_outgoing_envelope( &mut connections, OutgoingEnvelope::ToConnection { connection_id, @@ -771,10 +894,161 @@ mod tests { ) .await; - assert_eq!(disconnected, Vec::::new()); assert!( writer_rx.try_recv().is_err(), "opted-out notification should be dropped" ); } + + #[tokio::test] + async fn broadcast_does_not_block_on_slow_connection() { + let fast_connection_id = ConnectionId(1); + let slow_connection_id = ConnectionId(2); + + let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1); + let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1); + let fast_disconnect_token = CancellationToken::new(); + let slow_disconnect_token = CancellationToken::new(); + + let mut connections = HashMap::new(); + connections.insert( + fast_connection_id, + OutboundConnectionState::new( + fast_writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(fast_disconnect_token.clone()), + ), + ); + connections.insert( + slow_connection_id, + OutboundConnectionState::new( + slow_writer_tx.clone(), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + Some(slow_disconnect_token.clone()), + ), + ); + + let queued_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/already-buffered".to_string(), + params: None, + }); + slow_writer_tx + .try_send(queued_message) + .expect("channel should have room"); + + let broadcast_message = + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method: "codex/event/test".to_string(), + params: None, + }); + timeout( + Duration::from_millis(100), + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::Broadcast { + message: broadcast_message, + }, + ), + ) + .await + .expect("broadcast should not block on a full writer"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); + assert!(!fast_disconnect_token.is_cancelled()); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive broadcast"); + assert!(matches!( + fast_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/test" + )); + + let slow_message = slow_writer_rx + .try_recv() + .expect("slow connection should retain its original buffered message"); + assert!(matches!( + slow_message, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "codex/event/already-buffered" + )); + } + + #[tokio::test] + async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() { + let connection_id = ConnectionId(3); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + writer_tx + .send(OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "queued".to_string(), + params: None, + }, + )) + .await + .expect("channel should accept the first queued message"); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + None, + ), + ); + + let route_task = tokio::spawn(async move { + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::Notification( + crate::outgoing_message::OutgoingNotification { + method: "second".to_string(), + params: None, + }, + ), + }, + ) + .await + }); + + let first = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("first queued message should be readable") + .expect("first queued message should exist"); + let second = timeout(Duration::from_millis(100), writer_rx.recv()) + .await + .expect("second message should eventually be delivered") + .expect("second message should exist"); + + timeout(Duration::from_millis(100), route_task) + .await + .expect("routing should finish after writer drains") + .expect("routing task should succeed"); + + assert!(matches!( + first, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "queued" + )); + assert!(matches!( + second, + OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { + method, + params: None, + }) if method == "second" + )); + } } From 60c2b7beca5929346311388d50b1bdeb59673077 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 12:50:12 -0800 Subject: [PATCH 29/39] core tests: use hermetic mock server in review suite (#12291) ## Summary - switch the review test SSE mock helper to use the shared hermetic mock server setup - ensure review tests always have a default `/v1/models` stub during Codex session bootstrap - remove the race that caused intermittent `/v1/models` connection failures and flaky ETag refresh assertions ## Testing - `just fmt` - `cargo test -p codex-core --test all refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch` - `cargo test -p codex-core --test all review_uses_custom_review_model_from_config` - repeated both targeted tests 5x in a loop - `cargo clippy -p codex-core --tests -- -D warnings` --- codex-rs/core/tests/suite/review.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 1c9c3adf7b..113776b81f 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -872,7 +873,7 @@ async fn start_responses_server_with_sse( sse_raw: &str, expected_requests: usize, ) -> (MockServer, ResponseMock) { - let server = MockServer::start().await; + let server = start_mock_server().await; let sse = load_sse_fixture_with_id_from_str(sse_raw, &Uuid::new_v4().to_string()); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; From 354e7fedd25576d51220ee277bab5d4ca4f4f716 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 14:05:55 -0800 Subject: [PATCH 30/39] [apps] Enforce simple logo url format. (#12374) - [x] Enforce simple logo url format when loading apps directory to save bandwidth. --- codex-rs/app-server/tests/suite/v2/app_list.rs | 13 ++++++++++--- codex-rs/chatgpt/src/connectors.rs | 8 +++++--- 2 files changed, 15 insertions(+), 6 deletions(-) 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 3a2064d4cb..a9bc14e932 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -15,6 +15,7 @@ use axum::Router; use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; +use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppBranding; @@ -1215,6 +1216,7 @@ async fn start_apps_server_with_delays_and_control( async fn list_directory_connectors( State(state): State>, headers: HeaderMap, + uri: Uri, ) -> Result { if state.directory_delay > Duration::ZERO { tokio::time::sleep(state.directory_delay).await; @@ -1228,16 +1230,21 @@ async fn list_directory_connectors( .get("chatgpt-account-id") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == state.expected_account_id); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); - if bearer_ok && account_ok { + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { let response = state .response .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); Ok(Json(response)) - } else { - Err(StatusCode::UNAUTHORIZED) } } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 27f2f1de94..a3470ff1b9 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -220,9 +220,11 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result { let encoded_token = urlencoding::encode(token); - format!("/connectors/directory/list?tier=categorized&token={encoded_token}") + format!( + "/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true" + ) } - None => "/connectors/directory/list?tier=categorized".to_string(), + None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(), }; let response: DirectoryListResponse = chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) @@ -247,7 +249,7 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result anyhow::Result> { let response: anyhow::Result = chatgpt_get_request_with_timeout( config, - "/connectors/directory/list_workspace".to_string(), + "/connectors/directory/list_workspace?external_logos=true".to_string(), Some(DIRECTORY_CONNECTORS_TIMEOUT), ) .await; From 9176f09cb8300e90af322ed3850219ec9ab8da01 Mon Sep 17 00:00:00 2001 From: derekf-oai Date: Fri, 20 Feb 2026 14:12:13 -0800 Subject: [PATCH 31/39] docs: use --locked when installing cargo-nextest (#12377) ## What Updates the optional `cargo-nextest` install command in `docs/install.md`: - `cargo install cargo-nextest` -> `cargo install --locked cargo-nextest` ## Why The current docs command can fail during source install because recent `cargo-nextest` releases intentionally require `--locked`. Repro (macOS, but likely not platform-specific): - `cargo install cargo-nextest` - Fails with a compile error from `locked-tripwire` indicating: - `Nextest does not support being installed without --locked` - suggests `cargo install --locked cargo-nextest` Using the locked command succeeds: - `cargo install --locked cargo-nextest` ## How Single-line docs change in `docs/install.md` to match current `cargo-nextest` install requirements. ## Validation - Reproduced failure locally using a temporary `CARGO_HOME` directory (clean Cargo home) - Example command used: `CARGO_HOME=/tmp/cargo-home-test cargo install cargo-nextest` - Confirmed success with `cargo install --locked cargo-nextest` --- docs/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.md b/docs/install.md index ef14f5d840..16892c82fe 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ rustup component add clippy # Install helper tools used by the workspace justfile: cargo install just # Optional: install nextest for the `just test` helper -cargo install cargo-nextest +cargo install --locked cargo-nextest # Build Codex. cargo build From 1bb7989b20b25ff7e4017fc0f9d9e2de5ee4834b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 20 Feb 2026 14:26:14 -0800 Subject: [PATCH 32/39] Add ability to attach extra files to feedback (#12370) Allow clients to provide extra files. --- .../schema/json/ClientRequest.json | 9 +++++++++ .../json/codex_app_server_protocol.schemas.json | 9 +++++++++ .../schema/json/v2/FeedbackUploadParams.json | 9 +++++++++ .../typescript/v2/FeedbackUploadParams.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 9 +++++++-- codex-rs/feedback/src/lib.rs | 17 ++++++++++++++--- codex-rs/tui/src/bottom_pane/feedback_view.rs | 12 ++++++------ 9 files changed, 58 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9fc2a99141..76ba36735c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -507,6 +507,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, 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 7a01b04b51..313c3e62b3 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 @@ -12549,6 +12549,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index e4171b27f4..47b752b868 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -4,6 +4,15 @@ "classification": { "type": "string" }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "includeLogs": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 3066e65406..ebbe3b32d4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 103b5f56a0..833e628214 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1506,6 +1506,8 @@ pub struct FeedbackUploadParams { #[ts(optional = nullable)] pub thread_id: Option, pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 95ab4e43eb..8ee15688c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -150,7 +150,7 @@ Example with notification opt-out: - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); returns `{ started: true }` immediately and later emits `windowsSandbox/setupCompleted`. -- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. +- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 29d8acdbc1..65b7f1ecb7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6098,6 +6098,7 @@ impl CodexMessageProcessor { reason, thread_id, include_logs, + extra_log_files, } = params; let conversation_id = match thread_id.as_deref() { @@ -6127,15 +6128,19 @@ impl CodexMessageProcessor { } else { None }; + let mut attachment_paths = validated_rollout_path.into_iter().collect::>(); + if let Some(extra_log_files) = extra_log_files { + attachment_paths.extend(extra_log_files); + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { - let rollout_path_ref = validated_rollout_path.as_deref(); snapshot.upload_feedback( &classification, reason.as_deref(), include_logs, - rollout_path_ref, + &attachment_paths, Some(session_source), ) }) diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index adda7173a1..a4fe6cc185 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -224,7 +224,7 @@ impl CodexLogSnapshot { classification: &str, reason: Option<&str>, include_logs: bool, - rollout_path: Option<&std::path::Path>, + extra_log_files: &[PathBuf], session_source: Option, ) -> Result<()> { use std::collections::BTreeMap; @@ -317,11 +317,22 @@ impl CodexLogSnapshot { })); } - if let Some((path, data)) = rollout_path.and_then(|p| fs::read(p).ok().map(|d| (p, d))) { + for path in extra_log_files { + let data = match fs::read(path) { + Ok(data) => data, + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "failed to read log attachment; skipping" + ); + continue; + } + }; let fname = path .file_name() .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "rollout.jsonl".to_string()); + .unwrap_or_else(|| "extra-log.log".to_string()); let content_type = "text/plain".to_string(); envelope.add_item(EnvelopeItem::Attachment(Attachment { buffer: data, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 6cbfbdb9e5..eeca6aef18 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -87,7 +87,11 @@ impl FeedbackNoteView { } else { Some(note.as_str()) }; - let rollout_path_ref = self.rollout_path.as_deref(); + let log_file_paths = if self.include_logs { + self.rollout_path.iter().cloned().collect::>() + } else { + Vec::new() + }; let classification = feedback_classification(self.category); let mut thread_id = self.snapshot.thread_id.clone(); @@ -96,11 +100,7 @@ impl FeedbackNoteView { classification, reason_opt, self.include_logs, - if self.include_logs { - rollout_path_ref - } else { - None - }, + &log_file_paths, Some(SessionSource::Cli), ); From 64f3827d109c923759ca60c58751a0cd686c446a Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 20 Feb 2026 14:47:54 -0800 Subject: [PATCH 33/39] Move sanitizer into codex-secrets (#12306) ## Summary - move the sanitizer implementation into `codex-secrets` (`secrets/src/sanitizer.rs`) and re-export `redact_secrets` - switch `codex-core` to depend on/import `codex-secrets` for sanitizer usage - remove the old `utils/sanitizer` crate wiring and refresh lockfiles ## Testing - `just fmt` - `cargo test -p codex-secrets` - `cargo test -p codex-core --no-run` - `cargo clippy -p codex-secrets -p codex-core --all-targets --all-features -- -D warnings` - `just bazel-lock-update` - `just bazel-lock-check` ## Notes - not run: `cargo test --all-features` (full workspace suite) --- codex-rs/Cargo.lock | 10 ++-------- codex-rs/Cargo.toml | 2 -- codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/memories/phase1.rs | 2 +- codex-rs/secrets/BUILD.bazel | 6 ++++++ codex-rs/secrets/Cargo.toml | 1 + codex-rs/secrets/src/lib.rs | 2 ++ .../sanitizer/src/lib.rs => secrets/src/sanitizer.rs} | 0 codex-rs/utils/sanitizer/BUILD.bazel | 6 ------ codex-rs/utils/sanitizer/Cargo.toml | 11 ----------- 10 files changed, 13 insertions(+), 29 deletions(-) create mode 100644 codex-rs/secrets/BUILD.bazel rename codex-rs/{utils/sanitizer/src/lib.rs => secrets/src/sanitizer.rs} (100%) delete mode 100644 codex-rs/utils/sanitizer/BUILD.bazel delete mode 100644 codex-rs/utils/sanitizer/Cargo.toml diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7818b253cf..f05879a243 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1637,6 +1637,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-secrets", "codex-shell-command", "codex-state", "codex-utils-absolute-path", @@ -1644,7 +1645,6 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-pty", "codex-utils-readiness", - "codex-utils-sanitizer", "codex-utils-string", "codex-windows-sandbox", "core-foundation 0.9.4", @@ -2151,6 +2151,7 @@ dependencies = [ "keyring", "pretty_assertions", "rand 0.9.2", + "regex", "schemars 0.8.22", "serde", "serde_json", @@ -2439,13 +2440,6 @@ dependencies = [ "pretty_assertions", ] -[[package]] -name = "codex-utils-sanitizer" -version = "0.0.0" -dependencies = [ - "regex", -] - [[package]] name = "codex-utils-sleep-inhibitor" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e27b08b7f4..8ff0c456b8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -53,7 +53,6 @@ members = [ "utils/cli", "utils/elapsed", "utils/sandbox-summary", - "utils/sanitizer", "utils/sleep-inhibitor", "utils/approval-presets", "utils/oss", @@ -131,7 +130,6 @@ codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } -codex-utils-sanitizer = { path = "utils/sanitizer" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index fe7c2b5aa9..2b62d96254 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -48,7 +48,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } -codex-utils-sanitizer = { workspace = true } +codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index b356f61b38..d2fb6d11f4 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -20,7 +20,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::TokenUsage; -use codex_utils_sanitizer::redact_secrets; +use codex_secrets::redact_secrets; use futures::StreamExt; use serde::Deserialize; use serde_json::Value; diff --git a/codex-rs/secrets/BUILD.bazel b/codex-rs/secrets/BUILD.bazel new file mode 100644 index 0000000000..1b69e7c7e0 --- /dev/null +++ b/codex-rs/secrets/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "secrets", + crate_name = "codex_secrets", +) diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml index de45af50a1..eae8f9005e 100644 --- a/codex-rs/secrets/Cargo.toml +++ b/codex-rs/secrets/Cargo.toml @@ -13,6 +13,7 @@ anyhow = { workspace = true } base64 = { workspace = true } codex-keyring-store = { workspace = true } rand = { workspace = true } +regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index a45860d8b5..1ec925fd4f 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -13,8 +13,10 @@ use sha2::Digest; use sha2::Sha256; mod local; +mod sanitizer; pub use local::LocalSecretsBackend; +pub use sanitizer::redact_secrets; const KEYRING_SERVICE: &str = "codex"; diff --git a/codex-rs/utils/sanitizer/src/lib.rs b/codex-rs/secrets/src/sanitizer.rs similarity index 100% rename from codex-rs/utils/sanitizer/src/lib.rs rename to codex-rs/secrets/src/sanitizer.rs diff --git a/codex-rs/utils/sanitizer/BUILD.bazel b/codex-rs/utils/sanitizer/BUILD.bazel deleted file mode 100644 index 3cc7465bd7..0000000000 --- a/codex-rs/utils/sanitizer/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "sanitizer", - crate_name = "codex_utils_sanitizer", -) diff --git a/codex-rs/utils/sanitizer/Cargo.toml b/codex-rs/utils/sanitizer/Cargo.toml deleted file mode 100644 index 6f96cc891c..0000000000 --- a/codex-rs/utils/sanitizer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "codex-utils-sanitizer" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -regex = "1.12.3" - -[lints] -workspace = true From 65b9fe8f30c5889afc433181c3fb25a315633d79 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 20 Feb 2026 15:04:36 -0800 Subject: [PATCH 34/39] clarify model_catalog_json only applied on startup (#12379) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/config.schema.json | 2 +- codex-rs/core/src/config/mod.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e73a71bb30..4de4da6e48 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1807,7 +1807,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Optional path to a JSON file containing a complete model catalog. When set, this replaces the bundled catalog for this process." + "description": "Optional path to a JSON model catalog (applied on startup only). Per-thread `config` overrides are accepted but do not reapply this (no-ops)." }, "model_context_window": { "description": "Size of the context window for the model, in tokens.", diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b8e0382e8..76ba2fcb2d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1109,8 +1109,8 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, - /// Optional path to a JSON file containing a complete model catalog. - /// When set, this replaces the bundled catalog for this process. + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). pub model_catalog_json: Option, /// Optionally specify a personality for the model From 021e39b3031de7661245eff61ed9bbe11ffca531 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 20 Feb 2026 15:22:10 -0800 Subject: [PATCH 35/39] Show model/reasoning hint when switching modes (#12307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - show an info message when switching collaboration modes changes the effective model or reasoning - include the target mode in the message (for example `... for Plan mode.`) - add TUI tests for model-change and reasoning-only change notifications on mode switch Screenshot 2026-02-20 at 2 01 40 PM --- codex-rs/tui/src/chatwidget.rs | 27 +++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 67 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7dcab8010a..ca5aa7c620 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6343,9 +6343,36 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } + let previous_mode = self.active_mode_kind(); + let previous_model = self.current_model().to_string(); + let previous_effort = self.effective_reasoning_effort(); self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); self.refresh_model_display(); + let next_mode = self.active_mode_kind(); + let next_model = self.current_model(); + let next_effort = self.effective_reasoning_effort(); + if previous_mode != next_mode + && (previous_model != next_model || previous_effort != next_effort) + { + let mut message = format!("Model changed to {next_model}"); + if !next_model.starts_with("codex-auto-") { + let reasoning_label = match next_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + }; + message.push(' '); + message.push_str(reasoning_label); + } + message.push_str(" for "); + message.push_str(next_mode.display_name()); + message.push_str(" mode."); + self.add_info_message(message, None); + } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d6d2ab1ad7..edf0e01681 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3584,6 +3584,66 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { assert_eq!(chat.active_collaboration_mode_kind(), before); } +#[tokio::test] +async fn mode_switch_surfaces_model_change_notification_when_effective_model_changes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let default_model = chat.current_model().to_string(); + + let mut plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mode"); + plan_mask.model = Some("gpt-5.1-codex-mini".to_string()); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.1-codex-mini medium for Plan mode."), + "expected Plan-mode model switch notice, got: {plan_messages:?}" + ); + + let default_mask = collaboration_modes::default_mask(chat.models_manager.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + let default_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + let expected_default_message = + format!("Model changed to {default_model} default for Default mode."); + assert!( + default_messages.contains(&expected_default_message), + "expected Default-mode model switch notice, got: {default_messages:?}" + ); +} + +#[tokio::test] +async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_same() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let plan_mask = collaboration_modes::plan_mask(chat.models_manager.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + + let plan_messages = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + plan_messages.contains("Model changed to gpt-5.3-codex medium for Plan mode."), + "expected reasoning-change notice in Plan mode, got: {plan_messages:?}" + ); +} + #[tokio::test] async fn collab_slash_command_opens_picker_and_updates_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -3649,7 +3709,12 @@ async fn plan_slash_command_switches_to_plan_mode() { chat.dispatch_command(SlashCommand::Plan); - assert!(rx.try_recv().is_err(), "plan should not emit an app event"); + while let Ok(event) = rx.try_recv() { + assert!( + matches!(event, AppEvent::InsertHistoryCell(_)), + "plan should not emit a non-history app event: {event:?}" + ); + } assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_collaboration_mode(), &initial); } From 4ebdddaa3497e7fbd4cd80434015f016b9bdf119 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 16:47:15 -0800 Subject: [PATCH 36/39] [apps] Fix gateway url. (#12403) - [x] Fix connectors gateway url. --- codex-rs/core/src/mcp/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index f2c2557514..3abe4e9233 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -31,7 +31,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com"; -const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/mcp/"; +const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp"; // Legacy vs new MCP gateway #[derive(Debug, Clone, Copy, PartialEq, Eq)] From a5d0757ed15b4b53c216510c5bcfa4ecf3ca196e Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 20 Feb 2026 21:56:41 -0300 Subject: [PATCH 37/39] fix(tui): queued-message edit shortcut unreachable in some terminals (#12240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The TUI's "edit queued message" shortcut (Alt+Up) is either silently swallowed or recognized as another key combination by Apple Terminal, Warp, and VSCode's integrated terminal on macOS. Users in those environments see the hint but pressing the keys does nothing. ## Mental model When a model turn is in progress the user can still type follow-up messages. These are queued and displayed below the composer with a hint line showing how to pop the most recent one back into the editor. The hint text and the actual key handler must agree on which shortcut is used, and that shortcut must actually reach the TUI—i.e. it must not be intercepted by the host terminal. Three terminals are known to intercept Alt+Up: Apple Terminal (remaps it to cursor movement), Warp (consumes it for its own command palette), and VSCode (maps it to "move line up"). For these we use Shift+Left instead.

image

| macOS Native Terminal | Warp | VSCode Terminal | |---|---|---| | SCR-20260219-kigi | SCR-20260219-krrf | SCR-20260219-ksbz | ## Non-goals - Making the binding user-configurable at runtime (deferred to a broader keybinding-config effort). - Remapping any other shortcuts that might be terminal-specific. ## Tradeoffs - **Exhaustive match instead of a wildcard default.** The `queued_message_edit_binding_for_terminal` function explicitly lists every `TerminalName` variant. This is intentional: adding a new terminal to the enum will produce a compile error, forcing the author to decide which binding that terminal should use. - **Binding lives on `ChatWidget`, hint lives on `QueuedUserMessages`.** The key event handler that actually acts on the press is in `ChatWidget`, but the rendered hint text is inside `QueuedUserMessages`. These are kept in sync by `ChatWidget` calling `bottom_pane.set_queued_message_edit_binding(self.queued_message_edit_binding)` during construction. A mismatch would show the wrong hint but would not lose data. ## Architecture ```mermaid graph TD TI["terminal_info().name"] --> FN["queued_message_edit_binding_for_terminal(name)"] FN --> KB["KeyBinding"] KB --> CW["ChatWidget.queued_message_edit_binding
key event matching"] KB --> BP["BottomPane.set_queued_message_edit_binding()"] BP --> QUM["QueuedUserMessages.edit_binding
rendered in hint line"] subgraph "Special terminals (Shift+Left)" AT["Apple Terminal"] WT["Warp"] VS["VSCode"] end subgraph "Default (Alt+Up)" GH["Ghostty"] IT["iTerm2"] OT["Others…"] end AT --> FN WT --> FN VS --> FN GH --> FN IT --> FN OT --> FN ``` No new crates or public API surface. The only cross-crate dependency added is `codex_core::terminal::{TerminalName, terminal_info}`, which already existed for telemetry. ## Observability No new logging. Terminal detection already emits a `tracing::debug!` log line at startup with the detected terminal name, which is sufficient to diagnose binding mismatches. ## Tests - Existing `alt_up_edits_most_recent_queued_message` test is preserved and explicitly sets the Alt+Up binding to isolate from the host terminal. - New parameterized async tests verify Shift+Left works for Apple Terminal, Warp, and VSCode. - A sync unit test asserts the mapping table covers the three special terminals (Shift+Left) and that iTerm2 still gets Alt+Up. Fixes #4490 --- codex-rs/tui/src/bottom_pane/mod.rs | 7 ++ .../src/bottom_pane/queued_user_messages.rs | 18 ++++- codex-rs/tui/src/chatwidget.rs | 78 +++++++++++++++---- codex-rs/tui/src/chatwidget/tests.rs | 76 ++++++++++++++++++ 4 files changed, 165 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 43aecb4940..46917dd9fa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -287,6 +287,13 @@ impl BottomPane { self.request_redraw(); } + /// Update the key hint shown next to queued messages so it matches the + /// binding that `ChatWidget` actually listens for. + pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { + self.queued_user_messages.set_edit_binding(binding); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index ae33aeada4..010b88111c 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -11,17 +11,33 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; /// Widget that displays a list of user messages queued while a turn is in progress. +/// +/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the +/// user how to pop the most recent queued message back into the composer. +/// Because some terminals intercept certain modifier-key combinations, the +/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct QueuedUserMessages { pub messages: Vec, + /// Key combination rendered in the hint line. Defaults to Alt+Up but may + /// be overridden for terminals where that chord is unavailable. + edit_binding: key_hint::KeyBinding, } impl QueuedUserMessages { pub(crate) fn new() -> Self { Self { messages: Vec::new(), + edit_binding: key_hint::alt(KeyCode::Up), } } + /// Replace the keybinding shown in the hint line at the bottom of the + /// queued-messages list. The caller is responsible for also wiring the + /// corresponding key event handler. + pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) { + self.edit_binding = binding; + } + fn as_renderable(&self, width: u16) -> Box { if self.messages.is_empty() || width < 4 { return Box::new(()); @@ -48,7 +64,7 @@ impl QueuedUserMessages { lines.push( Line::from(vec![ " ".into(), - key_hint::alt(KeyCode::Up).into(), + self.edit_binding.into(), " edit".into(), ]) .dim(), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ca5aa7c620..afc894b22b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -110,6 +110,8 @@ use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; +use codex_core::terminal::terminal_info; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_otel::OtelManager; @@ -158,6 +160,34 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +/// Choose the keybinding used to edit the most-recently queued message. +/// +/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently +/// swallow Alt+Up, so users in those environments would never be able to trigger +/// the edit action. We fall back to Shift+Left for those terminals while +/// keeping the more discoverable Alt+Up everywhere else. +/// +/// The match is exhaustive so that adding a new `TerminalName` variant forces +/// an explicit decision about which binding that terminal should use. +fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding { + match terminal_name { + TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => { + key_hint::shift(KeyCode::Left) + } + TerminalName::Ghostty + | TerminalName::Iterm2 + | TerminalName::WezTerm + | TerminalName::Kitty + | TerminalName::Alacritty + | TerminalName::Konsole + | TerminalName::GnomeTerminal + | TerminalName::Vte + | TerminalName::WindowsTerminal + | TerminalName::Dumb + | TerminalName::Unknown => key_hint::alt(KeyCode::Up), + } +} + use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; @@ -563,6 +593,11 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + /// Terminal-appropriate keybinding for popping the most-recently queued + /// message back into the composer. Determined once at construction time via + /// [`queued_message_edit_binding_for_terminal`] and propagated to + /// `BottomPane` so the hint text matches the actual shortcut. + queued_message_edit_binding: KeyBinding, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press @@ -2605,6 +2640,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2662,6 +2699,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2702,6 +2740,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -2769,6 +2810,8 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.clone()); + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2830,6 +2873,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2866,6 +2910,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); widget } @@ -2922,6 +2969,8 @@ impl ChatWidget { settings: fallback_default, }; + let queued_message_edit_binding = + queued_message_edit_binding_for_terminal(terminal_info().name); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -2979,6 +3028,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + queued_message_edit_binding, show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, @@ -3019,6 +3069,9 @@ impl ChatWidget { widget.config.features.enabled(Feature::CollaborationModes), ); widget.sync_personality_command_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_binding); #[cfg(target_os = "windows")] widget.bottom_pane.set_windows_degraded_sandbox_active( codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -3091,6 +3144,18 @@ impl ChatWidget { _ => {} } + if key_event.kind == KeyEventKind::Press + && self.queued_message_edit_binding.is_press(key_event) + && !self.queued_user_messages.is_empty() + { + if let Some(user_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(user_message); + self.refresh_queued_user_messages(); + self.request_redraw(); + } + return; + } + match key_event { KeyEvent { code: KeyCode::BackTab, @@ -3102,19 +3167,6 @@ impl ChatWidget { { self.cycle_collaboration_mode(); } - KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - .. - } if !self.queued_user_messages.is_empty() => { - // Prefer the most recently queued item. - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(user_message); - self.refresh_queued_user_messages(); - self.request_redraw(); - } - } _ => match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted { text, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index edf0e01681..8646033284 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -67,6 +67,7 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::skills::model::SkillMetadata; +use codex_core::terminal::TerminalName; use codex_otel::OtelManager; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; @@ -1640,6 +1641,7 @@ async fn make_chatwidget_manual( frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), + queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, pending_notification: None, quit_shortcut_expires_at: None, @@ -2781,6 +2783,9 @@ async fn empty_enter_during_task_does_not_queue() { #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up); + chat.bottom_pane + .set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up)); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -2808,6 +2813,77 @@ async fn alt_up_edits_most_recent_queued_message() { ); } +async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( + terminal_name: TerminalName, +) { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name); + chat.bottom_pane + .set_queued_message_edit_binding(chat.queued_message_edit_binding); + + // Simulate a running task so messages would normally be queued. + chat.bottom_pane.set_task_running(true); + + // Seed two queued messages. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Press Shift+Left to edit the most recent (last) queued message. + chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); + + // Composer should now contain the last queued message. + assert_eq!( + chat.bottom_pane.composer_text(), + "second queued".to_string() + ); + // And the queue should now contain only the remaining (older) item. + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first queued" + ); +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal) + .await; +} + +#[tokio::test] +async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { + assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await; +} + +#[test] +fn queued_message_edit_binding_mapping_covers_special_terminals() { + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::VsCode), + crate::key_hint::shift(KeyCode::Left) + ); + assert_eq!( + queued_message_edit_binding_for_terminal(TerminalName::Iterm2), + crate::key_hint::alt(KeyCode::Up) + ); +} + /// Pressing Up to recall the most recent history entry and immediately queuing /// it while a task is running should always enqueue the same text, even when it /// is queued repeatedly. From 36a2a9fdbb11b07504a69c4f2fa90e63c1cbeb67 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 20 Feb 2026 17:35:07 -0800 Subject: [PATCH 38/39] [apps] Bump MCP tool call timeout. (#12405) - [x] Bump MCP tool call timeout. --- codex-rs/core/src/mcp_connection_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 05ac2a9a9b..12e3d325d3 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -92,7 +92,7 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); /// Default timeout for individual tool calls. -const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 1; const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; From 53bcfaf42d996d4e93e66c86f41a55b7ff3b56fc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Feb 2026 17:51:53 -0800 Subject: [PATCH 39/39] fix: explicitly list name collisions in JSON schema generation (#12406) ## Why JSON schema codegen was silently resolving naming collisions by appending numeric suffixes (for example `...2`, `...3`). That makes the generated schema names unstable: removing an earlier colliding type can cause a later type to be renumbered, which is a breaking change for consumers that referenced the old generated name. This PR makes those collisions explicit and reviewable. Though note that once we remove `v1` from the codegen, we will no longer support naming collisions. Or rather, naming collisions will have to be handled explicitly rather than the numeric suffix approach. ## What Changed - In `codex-rs/app-server-protocol/src/export.rs`, replaced implicit numeric suffix collision handling for generated variant titles with explicit special-case maps. - Added a panic when a collision occurs without an entry in the map, so new collisions fail loudly instead of silently renaming generated schema types. - Added the currently required special cases so existing generated names remain stable. - Extended the same approach to numbered `definitions` / `$defs` collisions (for example `MessagePhase2`-style names) so those are also explicitly tracked. ## Verification - Ran targeted generator-path test: - `cargo test -p codex-app-server-protocol generate_json_filters_experimental_fields_and_methods -- --nocapture` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/12406). * #12408 * __->__ #12406 --- codex-rs/app-server-protocol/src/export.rs | 239 ++++++++++++++++++++- 1 file changed, 235 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ec51dd1585..1a4edfdf57 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -32,10 +32,93 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::sync::LazyLock; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; +static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state", + "StateMcpStartupStatus2", + ), + ( + "base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state", + "StateMcpStartupStatus3", + ), + ]) + }); +static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock> = + LazyLock::new(|| { + HashMap::from([ + ( + "schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval", + "AskForApproval2", + ), + ( + "schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess", + "NetworkAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess", + "ReadOnlyAccess2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode", + "SandboxMode2", + ), + ( + "schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy", + "SandboxPolicy2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange", + "ByteRange2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo", + "CodexErrorInfo2", + ), + ( + "schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot", + "CreditsSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase", + "MessagePhase2", + ), + ( + "schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason", + "ModelRerouteReason2", + ), + ( + "schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus", + "PatchApplyStatus2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot", + "RateLimitSnapshot2", + ), + ( + "schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow", + "RateLimitWindow2", + ), + ( + "schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement", + "TextElement2", + ), + ( + "schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput", + "UserInput2", + ), + ( + "schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction", + "WebSearchAction2", + ), + ]) + }); #[derive(Clone)] pub struct GeneratedSchema { @@ -950,6 +1033,7 @@ where let file_stem = name.trim(); let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; + enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value); annotate_schema(&mut schema_value, Some(file_stem)); // If the name looks like a namespaced path (e.g., "v2::Type"), mirror // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise @@ -980,6 +1064,101 @@ where }) } +fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) { + for defs_key in ["definitions", "$defs"] { + let renames = { + let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else { + continue; + }; + collect_numbered_definition_collision_renames(schema_name, defs_key, defs) + }; + + if renames.is_empty() { + continue; + } + + #[expect(clippy::expect_used)] + let defs = schema + .get_mut(defs_key) + .and_then(Value::as_object_mut) + .expect("definition map should still be present"); + for (old_name, new_name, collision_key) in &renames { + if old_name == new_name { + continue; + } + assert!( + !defs.contains_key(new_name), + "Numbered definition collision override produced duplicate definition {new_name} for {collision_key}", + ); + let schema_value = defs.remove(old_name).unwrap_or_else(|| { + panic!("Missing generated numbered definition {old_name} for {collision_key}") + }); + defs.insert(new_name.clone(), schema_value); + } + + for (old_name, new_name, _) in renames { + if old_name != new_name { + rewrite_named_local_ref(schema, defs_key, &old_name, &new_name); + } + } + } +} + +fn collect_numbered_definition_collision_renames( + schema_name: &str, + defs_key: &str, + defs: &Map, +) -> Vec<(String, String, String)> { + let mut renames = Vec::new(); + + for generated_name in defs.keys() { + let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit()); + if base_name == generated_name || !defs.contains_key(base_name) { + continue; + } + + let collision_key = format!( + "schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}" + ); + let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!("Missing numbered definition collision override for {collision_key}") + }); + renames.push((generated_name.clone(), replacement, collision_key)); + } + + renames +} + +fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) { + let direct = format!("#/{defs_key}/{old_name}"); + let prefixed = format!("{direct}/"); + let replacement = format!("#/{defs_key}/{new_name}"); + let replacement_prefixed = format!("{replacement}/"); + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + if reference == &direct { + *reference = replacement; + } else if let Some(rest) = reference.strip_prefix(&prefixed) { + *reference = format!("{replacement_prefixed}{rest}"); + } + } + for child in obj.values_mut() { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + Value::Array(items) => { + for child in items { + rewrite_named_local_ref(child, defs_key, old_name, new_name); + } + } + _ => {} + } +} + pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result where T: JsonSchema, @@ -1198,10 +1377,20 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { && let Some(name) = variant_definition_name(base_name, variant) { let mut candidate = name.clone(); - let mut index = 2; - while seen.contains(&candidate) { - candidate = format!("{name}{index}"); - index += 1; + if seen.contains(&candidate) { + let collision_key = variant_title_collision_key(base_name, &name, variant); + candidate = VARIANT_TITLE_COLLISION_OVERRIDES + .get(collision_key.as_str()) + .map(|value| (*value).to_string()) + .unwrap_or_else(|| { + panic!( + "Missing variant title collision override for {collision_key} (generated name: {name})" + ) + }); + assert!( + !seen.contains(&candidate), + "Variant title collision override produced duplicate title {candidate} for {collision_key}", + ); } if let Some(obj) = variant.as_object_mut() { obj.insert("title".into(), Value::String(candidate.clone())); @@ -1221,6 +1410,48 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { } } +fn variant_title_collision_key(base: &str, generated_name: &str, variant: &Value) -> String { + let mut parts = vec![ + format!("base={base}"), + format!("generated={generated_name}"), + ]; + + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + for key in DISCRIMINATOR_KEYS { + if let Some(value) = literal_from_property(props, key) { + parts.push(format!("{key}={value}")); + } + } + for (key, value) in props { + if DISCRIMINATOR_KEYS.contains(&key.as_str()) { + continue; + } + if let Some(literal) = string_literal(value) { + parts.push(format!("literal:{key}={literal}")); + } + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + parts.push(format!("only_property={key}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + parts.push(format!("required_only={key}")); + } + + if parts.len() == 2 { + parts.push(format!("variant={variant}")); + } + + parts.join("|") +} + const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"]; fn set_discriminator_titles(props: &mut Map, owner: &str) {