From d5853d9c47b1badad183f62622745cf47e6ff0f4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 1 Nov 2025 16:52:23 -0500 Subject: [PATCH 001/122] Changes to sandbox command assessment feature based on initial experiment feedback (#6091) * Removed sandbox risk categories; feedback indicates that these are not that useful and "less is more" * Tweaked the assessment prompt to generate terser answers * Fixed bug in orchestrator that prevents this feature from being exposed in the extension --- codex-rs/core/src/sandboxing/assessment.rs | 28 ++++--------------- codex-rs/core/src/tools/orchestrator.rs | 11 +++++++- .../templates/sandboxing/assessment_prompt.md | 11 +++----- codex-rs/otel/src/otel_event_manager.rs | 12 -------- codex-rs/protocol/src/approvals.rs | 28 ------------------- codex-rs/protocol/src/protocol.rs | 1 - .../tui/src/bottom_pane/approval_overlay.rs | 27 +----------------- 7 files changed, 20 insertions(+), 98 deletions(-) diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index c7310c1f13..31e76777bb 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -25,16 +25,6 @@ use tracing::warn; const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(5); -const SANDBOX_RISK_CATEGORY_VALUES: &[&str] = &[ - "data_deletion", - "data_exfiltration", - "privilege_escalation", - "system_modification", - "network_access", - "resource_exhaustion", - "compliance", -]; - #[derive(Template)] #[template(path = "sandboxing/assessment_prompt.md", escape = "none")] struct SandboxAssessmentPromptTemplate<'a> { @@ -176,27 +166,26 @@ pub(crate) async fn assess_command( call_id, "success", Some(assessment.risk_level), - &assessment.risk_categories, duration, ); return Some(assessment); } Err(err) => { warn!("failed to parse sandbox assessment JSON: {err}"); - parent_otel.sandbox_assessment(call_id, "parse_error", None, &[], duration); + parent_otel.sandbox_assessment(call_id, "parse_error", None, duration); } }, Ok(Ok(None)) => { warn!("sandbox assessment response did not include any message"); - parent_otel.sandbox_assessment(call_id, "no_output", None, &[], duration); + parent_otel.sandbox_assessment(call_id, "no_output", None, duration); } Ok(Err(err)) => { warn!("sandbox assessment failed: {err}"); - parent_otel.sandbox_assessment(call_id, "model_error", None, &[], duration); + parent_otel.sandbox_assessment(call_id, "model_error", None, duration); } Err(_) => { warn!("sandbox assessment timed out"); - parent_otel.sandbox_assessment(call_id, "timeout", None, &[], duration); + parent_otel.sandbox_assessment(call_id, "timeout", None, duration); } } @@ -229,7 +218,7 @@ fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec fn sandbox_assessment_schema() -> serde_json::Value { json!({ "type": "object", - "required": ["description", "risk_level", "risk_categories"], + "required": ["description", "risk_level"], "properties": { "description": { "type": "string", @@ -240,13 +229,6 @@ fn sandbox_assessment_schema() -> serde_json::Value { "type": "string", "enum": ["low", "medium", "high"] }, - "risk_categories": { - "type": "array", - "items": { - "type": "string", - "enum": SANDBOX_RISK_CATEGORY_VALUES - } - } }, "additionalProperties": false }) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index f334c4ef55..878e48e8be 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -54,12 +54,21 @@ impl ToolOrchestrator { let mut already_approved = false; if needs_initial_approval { + let mut risk = None; + + if let Some(metadata) = req.sandbox_retry_data() { + risk = tool_ctx + .session + .assess_sandbox_command(turn_ctx, &tool_ctx.call_id, &metadata.command, None) + .await; + } + let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: None, - risk: None, + risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md index 7e5664a0ad..dc59f47d7c 100644 --- a/codex-rs/core/templates/sandboxing/assessment_prompt.md +++ b/codex-rs/core/templates/sandboxing/assessment_prompt.md @@ -1,13 +1,10 @@ -You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk. Return strictly valid JSON with the keys: -- description (concise summary, at most two sentences) +You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk to help the user decide whether to approve command execution. Return strictly valid JSON with the keys: +- description (concise summary of command intent and potential effects, no more than one sentence, use present tense) - risk_level ("low", "medium", or "high") -- risk_categories (optional array of zero or more category strings) Risk level examples: -- low: read-only inspections, listing files, printing configuration -- medium: modifying project files, installing dependencies, fetching artifacts from trusted sources +- low: read-only inspections, listing files, printing configuration, fetching artifacts from trusted sources +- medium: modifying project files, installing dependencies - high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls -Recognized risk_categories: data_deletion, data_exfiltration, privilege_escalation, system_modification, network_access, resource_exhaustion, compliance. -Use multiple categories when appropriate. If information is insufficient, choose the most cautious risk level supported by the evidence. Respond with JSON only, without markdown code fences or extra commentary. diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 4006df17d9..5d9cbd4997 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,7 +8,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SandboxRiskCategory; use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; @@ -373,19 +372,9 @@ impl OtelEventManager { call_id: &str, status: &str, risk_level: Option, - risk_categories: &[SandboxRiskCategory], duration: Duration, ) { let level = risk_level.map(|level| level.as_str()); - let categories = if risk_categories.is_empty() { - String::new() - } else { - risk_categories - .iter() - .map(SandboxRiskCategory::as_str) - .collect::>() - .join(", ") - }; tracing::event!( tracing::Level::INFO, @@ -402,7 +391,6 @@ impl OtelEventManager { call_id = %call_id, status = %status, risk_level = level, - risk_categories = categories, duration_ms = %duration.as_millis(), ); } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index d608dba639..3227ddd1a3 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -16,24 +16,10 @@ pub enum SandboxRiskLevel { High, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum SandboxRiskCategory { - DataDeletion, - DataExfiltration, - PrivilegeEscalation, - SystemModification, - NetworkAccess, - ResourceExhaustion, - Compliance, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct SandboxCommandAssessment { pub description: String, pub risk_level: SandboxRiskLevel, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub risk_categories: Vec, } impl SandboxRiskLevel { @@ -46,20 +32,6 @@ impl SandboxRiskLevel { } } -impl SandboxRiskCategory { - pub fn as_str(&self) -> &'static str { - match self { - Self::DataDeletion => "data_deletion", - Self::DataExfiltration => "data_exfiltration", - Self::PrivilegeEscalation => "privilege_escalation", - Self::SystemModification => "system_modification", - Self::NetworkAccess => "network_access", - Self::ResourceExhaustion => "resource_exhaustion", - Self::Compliance => "compliance", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 413a3aeaa2..c30ba42b56 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -37,7 +37,6 @@ use ts_rs::TS; pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::SandboxCommandAssessment; -pub use crate::approvals::SandboxRiskCategory; pub use crate::approvals::SandboxRiskLevel; /// Open/close tags for special user-input blocks. Used across crates to avoid diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index ba36870005..140b5386c4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -20,7 +20,6 @@ use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskCategory; use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -356,35 +355,11 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { ])); } - let mut spans: Vec> = vec!["Risk: ".into(), level_span]; - if !risk.risk_categories.is_empty() { - spans.push(" (".into()); - for (idx, category) in risk.risk_categories.iter().enumerate() { - if idx > 0 { - spans.push(", ".into()); - } - spans.push(risk_category_label(*category).into()); - } - spans.push(")".into()); - } - - lines.push(Line::from(spans)); + lines.push(vec!["Risk: ".into(), level_span].into()); lines.push(Line::from("")); lines } -fn risk_category_label(category: SandboxRiskCategory) -> &'static str { - match category { - SandboxRiskCategory::DataDeletion => "data deletion", - SandboxRiskCategory::DataExfiltration => "data exfiltration", - SandboxRiskCategory::PrivilegeEscalation => "privilege escalation", - SandboxRiskCategory::SystemModification => "system modification", - SandboxRiskCategory::NetworkAccess => "network access", - SandboxRiskCategory::ResourceExhaustion => "resource exhaustion", - SandboxRiskCategory::Compliance => "compliance", - } -} - #[derive(Clone)] enum ApprovalVariant { Exec { id: String, command: Vec }, From 0c7efa0cfdf9152265a1b6aa21ebba6e0af5be58 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 2 Nov 2025 18:33:09 -0600 Subject: [PATCH 002/122] Fix incorrect "deprecated" message about experimental config key (#6131) When I enable `experimental_sandbox_command_assessment`, I get an incorrect deprecation warning: "experimental_sandbox_command_assessment is deprecated. Use experimental_sandbox_command_assessment instead." This PR fixes this error. --- codex-rs/core/src/features/legacy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index acf96a15b6..de3c007bac 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -123,7 +123,7 @@ fn set_if_some( if let Some(enabled) = maybe_value { set_feature(features, feature, enabled); log_alias(alias_key, feature); - features.record_legacy_usage_force(alias_key, feature); + features.record_legacy_usage(alias_key, feature); } } From 5fcf923c19ea0ef62164602528ec483850738d76 Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Sun, 2 Nov 2025 23:19:08 -0500 Subject: [PATCH 003/122] fix: pasting api key stray character (#4903) When signing in with an API key, pasting (with command+v on mac) adds a stray `v` character to the end of the api key. demo video (where I'm pasting in `sk-something-super-secret`) https://github.com/user-attachments/assets/b2b34b5f-c7e4-4760-9657-c35686dd8bb8 --- codex-rs/tui/src/onboarding/auth.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 56527ac8ef..06e1c629e3 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -10,6 +10,7 @@ use codex_login::ShutdownHandle; use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; @@ -428,7 +429,9 @@ impl AuthModeWidget { should_request_frame = true; } KeyCode::Char(c) - if !key_event.modifiers.contains(KeyModifiers::CONTROL) + if key_event.kind == KeyEventKind::Press + && !key_event.modifiers.contains(KeyModifiers::SUPER) + && !key_event.modifiers.contains(KeyModifiers::CONTROL) && !key_event.modifiers.contains(KeyModifiers::ALT) => { if state.prepopulated_from_env { From f5945d7c0322bfdd701275a1083f9f2f1cafe27b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:38:45 -0800 Subject: [PATCH 004/122] chore(deps): bump actions/upload-artifact from 4 to 5 (#6137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
Release notes

Sourced from actions/upload-artifact's releases.

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0

v4.6.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.2

v4.6.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.1

v4.6.0

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.0

v4.5.0

What's Changed

New Contributors

... (truncated)

Commits
  • 330a01c Merge pull request #734 from actions/danwkennedy/prepare-5.0.0
  • 03f2824 Update github.dep.yml
  • 905a1ec Prepare v5.0.0
  • 2d9f9cd Merge pull request #725 from patrikpolyak/patch-1
  • 9687587 Merge branch 'main' into patch-1
  • 2848b2c Merge pull request #727 from danwkennedy/patch-1
  • 9b51177 Spell out the first use of GHES
  • cd231ca Update GHES guidance to include reference to Node 20 version
  • de65e23 Merge pull request #712 from actions/nebuk89-patch-1
  • 8747d8c Update README.md
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/rust-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fa732a492..38773bb9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: codex-npm-staging path: ${{ steps.stage_npm_package.outputs.pack_output }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5beadd55c4..26afdaf552 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -350,7 +350,7 @@ jobs: fi fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ matrix.target }} # Upload the per-binary .zst files as well as the new .tar.gz From dccce34d84b39bab304f9949b72db3e3d4bc5850 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 2 Nov 2025 23:41:05 -0600 Subject: [PATCH 005/122] Fix "archive conversation" on Windows (#6124) Addresses issue https://github.com/openai/codex/issues/3582 where an "archive conversation" command in the extension fails on Windows. The problem is that the `archive_conversation` api server call is not canonicalizing the path to the rollout path when performing its check to verify that the rollout path is in the sessions directory. This causes it to fail 100% of the time on Windows. Testing: I was able to repro the error on Windows 100% prior to this change. After the change, I'm no longer able to repro. --- .../app-server/src/codex_message_processor.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 99310e198f..7cd60eaeda 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1172,9 +1172,23 @@ impl CodexMessageProcessor { // Verify that the rollout path is in the sessions directory or else // a malicious client could specify an arbitrary path. let rollout_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR); + let canonical_sessions_dir = match tokio::fs::canonicalize(&rollout_folder).await { + Ok(path) => path, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to archive conversation: unable to resolve sessions directory: {err}" + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; let canonical_rollout_path = tokio::fs::canonicalize(&rollout_path).await; let canonical_rollout_path = if let Ok(path) = canonical_rollout_path - && path.starts_with(&rollout_folder) + && path.starts_with(&canonical_sessions_dir) { path } else { From a1ee10b43815a10da3ac09111d793d3ded1b0dc7 Mon Sep 17 00:00:00 2001 From: Vinh Nguyen <1097578+vinhnx@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:44:59 +0700 Subject: [PATCH 006/122] fix: improve usage URLs in status card and snapshots (#6111) Hi OpenAI Codex team, currently "Visit chatgpt.com/codex/settings/usage for up-to-date information on rate limits and credits" message in status card and error messages. For now, without the "https://" prefix, the link cannot be clicked directly from most terminals or chat interfaces. Screenshot 2025-11-02 at 22 47 06 --- The fix is intent to improve this issue: - It makes the link clickable in terminals that support it, hence better accessibility - It follows standard URL formatting practices - It maintains consistency with other links in the application (like the existing "https://openai.com/chatgpt/pricing" links) Thank you! --- codex-rs/core/src/error.rs | 10 +++++----- codex-rs/tui/src/status/card.rs | 4 +++- ..._tests__status_snapshot_includes_monthly_limit.snap | 2 +- ...ts__status_snapshot_includes_reasoning_details.snap | 2 +- ...ts__status_snapshot_shows_empty_limits_message.snap | 2 +- ...__status_snapshot_shows_missing_limits_message.snap | 2 +- ...ts__status_snapshot_shows_stale_limits_message.snap | 2 +- ...__status_snapshot_truncates_in_narrow_terminal.snap | 5 +++-- 8 files changed, 16 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index be1daf483a..6468327543 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -255,7 +255,7 @@ impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let message = match self.plan_type.as_ref() { Some(PlanType::Known(KnownPlan::Plus)) => format!( - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit chatgpt.com/codex/settings/usage to purchase more credits{}", + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", retry_suffix_after_or(self.resets_at.as_ref()) ), Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { @@ -269,7 +269,7 @@ impl std::fmt::Display for UsageLimitReachedError { .to_string() } Some(PlanType::Known(KnownPlan::Pro)) => format!( - "You've hit your usage limit. Visit chatgpt.com/codex/settings/usage to purchase more credits{}", + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}", retry_suffix_after_or(self.resets_at.as_ref()) ), Some(PlanType::Known(KnownPlan::Enterprise)) @@ -460,7 +460,7 @@ mod tests { }; assert_eq!( err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit chatgpt.com/codex/settings/usage to purchase more credits or try again later." + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later." ); } @@ -613,7 +613,7 @@ mod tests { rate_limits: Some(rate_limit_snapshot()), }; let expected = format!( - "You've hit your usage limit. Visit chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." ); assert_eq!(err.to_string(), expected); }); @@ -647,7 +647,7 @@ mod tests { rate_limits: Some(rate_limit_snapshot()), }; let expected = format!( - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." ); assert_eq!(err.to_string(), expected); }); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index fdf6629a0a..11b723fef9 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -313,7 +313,9 @@ impl HistoryCell for StatusHistoryCell { let note_first_line = Line::from(vec![ Span::from("Visit ").cyan(), - "chatgpt.com/codex/settings/usage".cyan().underlined(), + "https://chatgpt.com/codex/settings/usage" + .cyan() + .underlined(), Span::from(" for up-to-date").cyan(), ]); let note_second_line = Line::from(vec![ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index cf45f3f810..85e6356c34 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -7,7 +7,7 @@ expression: sanitized ╭────────────────────────────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for up-to-date │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index e38a1c7c1b..0b7b74d745 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -7,7 +7,7 @@ expression: sanitized ╭─────────────────────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for up-to-date │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning high, summaries detailed) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap index 82ed8fc0b9..17862db238 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_empty_limits_message.snap @@ -7,7 +7,7 @@ expression: sanitized ╭─────────────────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for up-to-date │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 82ed8fc0b9..17862db238 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -7,7 +7,7 @@ expression: sanitized ╭─────────────────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for up-to-date │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index bd18719700..2fc0d88744 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -7,7 +7,7 @@ expression: sanitized ╭─────────────────────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for up-to-date │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning none, summaries auto) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 64e9271df7..d86e43a458 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -1,5 +1,6 @@ --- source: tui/src/status/tests.rs +assertion_line: 257 expression: sanitized --- /status @@ -7,8 +8,8 @@ expression: sanitized ╭────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ -│ Visit chatgpt.com/codex/settings/usage for │ -│ up-to-date │ +│ Visit https://chatgpt.com/codex/settings/ │ +│ usage for up-to-date │ │ information on rate limits and credits │ │ │ │ Model: gpt-5-codex (reasoning │ From b48467296103eaaa32823464004a5d304ba23417 Mon Sep 17 00:00:00 2001 From: pygarap Date: Mon, 3 Nov 2025 18:27:13 +0200 Subject: [PATCH 007/122] Add documentation for slash commands in `docs/slash_commands.md`. (#5685) This pull request adds a new documentation section to explain the available slash commands in Codex. The update introduces a clear overview and a reference table for built-in commands, making it easier for users to understand and utilize these features. Documentation updates: * Added a new section to `docs/slash_commands.md` describing what slash commands are and listing all built-in commands with their purposes in a formatted table. --- README.md | 1 + docs/slash_commands.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 docs/slash_commands.md diff --git a/README.md b/README.md index abd611e7bb..5b2b65b0b2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Getting started**](./docs/getting-started.md) - [CLI usage](./docs/getting-started.md#cli-usage) + - [Slash Commands](./docs/slash_commands.md) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) - [Custom prompts](./docs/prompts.md) diff --git a/docs/slash_commands.md b/docs/slash_commands.md new file mode 100644 index 0000000000..4c1a244764 --- /dev/null +++ b/docs/slash_commands.md @@ -0,0 +1,31 @@ +## Slash Commands + +### What are slash commands? + +Slash commands are special commands you can type that start with `/`. + +--- + +### Built-in slash commands + +Control Codex’s behavior during an interactive session with slash commands. + +| Command | Purpose | +| ------------ | ----------------------------------------------------------- | +| `/model` | choose what model and reasoning effort to use | +| `/approvals` | choose what Codex can do without approval | +| `/review` | review my current changes and find issues | +| `/new` | start a new chat during a conversation | +| `/init` | create an AGENTS.md file with instructions for Codex | +| `/compact` | summarize conversation to prevent hitting the context limit | +| `/undo` | ask Codex to undo a turn | +| `/diff` | show git diff (including untracked files) | +| `/mention` | mention a file | +| `/status` | show current session configuration and token usage | +| `/mcp` | list configured MCP tools | +| `/logout` | log out of Codex | +| `/quit` | exit Codex | +| `/exit` | exit Codex | +| `/feedback` | send logs to maintainers | + +--- From 4d8b71d412e6ebe4d6696b38dc2d846c8d383746 Mon Sep 17 00:00:00 2001 From: Mark Hemmings Date: Mon, 3 Nov 2025 16:59:00 +0000 Subject: [PATCH 008/122] Fix typo in error message for OAuth login (#6159) Error message for attempting to OAuth with a remote RCP is incorrect and misleading. The correct config is ``` [features] rmcp_client = true ``` Co-authored-by: Eric Traut --- codex-rs/cli/src/mcp_cmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 274717b318..be08cfb683 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -353,7 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) .context("failed to load configuration")?; if !config.features.enabled(Feature::RmcpClient) { - bail!("OAuth login is only supported when [feature].rmcp_client is true in config.toml."); + bail!("OAuth login is only supported when [features].rmcp_client is true in config.toml."); } let LoginArgs { name, scopes } = login_args; From 7bc3ca9e40e7b7d174468339029488217bb35141 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:59:19 -0800 Subject: [PATCH 009/122] Fix rmcp client feature flag reference (#6051) ## Summary - update the OAuth login error message to reference `[features].rmcp_client` in config ## Testing - cargo test -p codex-cli ------ https://chatgpt.com/codex/tasks/task_i_69050365dc84832ca298f863c879a59a From e5e13479d04bba8da2cb56db6cc829e66f98da5d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 3 Nov 2025 10:02:23 -0800 Subject: [PATCH 010/122] Include reasoning tokens in the context window calculation (#6161) This value is used to determine whether mid-turn compaction is required. Reasoning items are only excluded between turns (and soon will start to be preserved even across turns) so it's incorrect to subtract reasoning_output_tokens mid term. This will result in higher values reported between turns but we are also looking into preserving reasoning items for the entire conversation to improve performance and caching. --- codex-rs/protocol/src/protocol.rs | 6 +----- ...__tests__status_snapshot_includes_reasoning_details.snap | 2 +- ...__tests__status_snapshot_shows_stale_limits_message.snap | 2 +- ...tests__status_snapshot_truncates_in_narrow_terminal.snap | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c30ba42b56..2d0b0f013a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -812,12 +812,8 @@ impl TokenUsage { (self.non_cached_input() + self.output_tokens.max(0)).max(0) } - /// For estimating what % of the model's context window is used, we need to account - /// for reasoning output tokens from prior turns being dropped from the context window. - /// We approximate this here by subtracting reasoning output tokens from the total. - /// This will be off for the current turn and pending function calls. pub fn tokens_in_context_window(&self) -> i64 { - (self.total_tokens - self.reasoning_output_tokens).max(0) + self.total_tokens } /// Estimate the remaining user-controllable percentage of the model's context window. diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 0b7b74d745..123547cb56 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -17,7 +17,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.1K used / 272K) │ +│ Context window: 100% left (2.25K used / 272K) │ │ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ │ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │ ╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index 2fc0d88744..3a98ce95ca 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -17,7 +17,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.1K used / 272K) │ +│ Context window: 100% left (2.25K used / 272K) │ │ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ │ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │ │ Warning: limits may be stale - start new turn to refresh. │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index d86e43a458..5bbaaef7cd 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -19,7 +19,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + │ -│ Context window: 100% left (2.1K used / │ +│ Context window: 100% left (2.25K used / │ │ 5h limit: [███████████████░░░░░] │ │ (resets 03:14) │ ╰────────────────────────────────────────────╯ From e1f098b9b721d473fb500fb16bd38b4edfe9b394 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 3 Nov 2025 10:06:00 -0800 Subject: [PATCH 011/122] feat: add options to responses-api-proxy to support Azure (#6129) This PR introduces an `--upstream-url` option to the proxy CLI that determines the URL that Responses API requests should be forwarded to. To preserve existing behavior, the default value is `"https://api.openai.com/v1/responses"`. The motivation for this change is that the [Codex GitHub Action](https://github.com/openai/codex-action) should support those who use the OpenAI Responses API via Azure. Relevant issues: - https://github.com/openai/codex-action/issues/28 - https://github.com/openai/codex-action/issues/38 - https://github.com/openai/codex-action/pull/44 Though rather than introduce a bunch of new Azure-specific logic in the action as https://github.com/openai/codex-action/pull/44 proposes, we should leverage our Responses API proxy to get the _hardening_ benefits it provides: https://github.com/openai/codex/blob/d5853d9c47b1badad183f62622745cf47e6ff0f4/codex-rs/responses-api-proxy/README.md#hardening-details This PR should make this straightforward to incorporate in the action. To see how the updated version of the action would consume these new options, see https://github.com/openai/codex-action/pull/47. --- codex-rs/responses-api-proxy/README.md | 15 ++++++- codex-rs/responses-api-proxy/src/lib.rs | 39 ++++++++++++++++--- .../responses-api-proxy/src/read_api_key.rs | 18 ++++----- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 1109271c1d..8a99c41a26 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -40,12 +40,23 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ## CLI ``` -codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] [--upstream-url ] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. - `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": , "pid": }` once listening. - `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. +- `--upstream-url `: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`. +- Authentication is fixed to `Authorization: Bearer ` to match the Codex CLI expectations. + +For Azure, for example (ensure your deployment accepts `Authorization: Bearer `): + +```shell +printenv AZURE_OPENAI_API_KEY | env -u AZURE_OPENAI_API_KEY codex-responses-api-proxy \ + --http-shutdown \ + --server-info /tmp/server-info.json \ + --upstream-url "https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT/responses?api-version=2025-04-01-preview" +``` ## Notes @@ -57,7 +68,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory: - We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques. -- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes. +- At startup, we allocate a `1024` byte buffer on the stack and copy `"Bearer "` into the start of the buffer. - We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. - After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap). - We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 50d003bc42..bbe0484d4a 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use clap::Parser; +use reqwest::Url; use reqwest::blocking::Client; use reqwest::header::AUTHORIZATION; use reqwest::header::HOST; @@ -44,6 +45,10 @@ pub struct Args { /// Enable HTTP shutdown endpoint at GET /shutdown #[arg(long)] pub http_shutdown: bool, + + /// Absolute URL the proxy should forward requests to (defaults to OpenAI). + #[arg(long, default_value = "https://api.openai.com/v1/responses")] + pub upstream_url: String, } #[derive(Serialize)] @@ -52,10 +57,29 @@ struct ServerInfo { pid: u32, } +struct ForwardConfig { + upstream_url: Url, + host_header: HeaderValue, +} + /// Entry point for the library main, for parity with other crates. pub fn run_main(args: Args) -> Result<()> { let auth_header = read_auth_header_from_stdin()?; + let upstream_url = Url::parse(&args.upstream_url).context("parsing --upstream-url")?; + let host = match (upstream_url.host_str(), upstream_url.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + _ => return Err(anyhow!("upstream URL must include a host")), + }; + let host_header = + HeaderValue::from_str(&host).context("constructing Host header from upstream URL")?; + + let forward_config = Arc::new(ForwardConfig { + upstream_url, + host_header, + }); + let (listener, bound_addr) = bind_listener(args.port)?; if let Some(path) = args.server_info.as_ref() { write_server_info(path, bound_addr.port())?; @@ -75,13 +99,14 @@ pub fn run_main(args: Args) -> Result<()> { let http_shutdown = args.http_shutdown; for request in server.incoming_requests() { let client = client.clone(); + let forward_config = forward_config.clone(); std::thread::spawn(move || { if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" { let _ = request.respond(Response::new_empty(StatusCode(200))); std::process::exit(0); } - if let Err(e) = forward_request(&client, auth_header, request) { + if let Err(e) = forward_request(&client, auth_header, &forward_config, request) { eprintln!("forwarding error: {e}"); } }); @@ -115,7 +140,12 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> { Ok(()) } -fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> { +fn forward_request( + client: &Client, + auth_header: &'static str, + config: &ForwardConfig, + mut req: Request, +) -> Result<()> { // Only allow POST /v1/responses exactly, no query string. let method = req.method().clone(); let url_path = req.url().to_string(); @@ -157,11 +187,10 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) auth_header_value.set_sensitive(true); headers.insert(AUTHORIZATION, auth_header_value); - headers.insert(HOST, HeaderValue::from_static("api.openai.com")); + headers.insert(HOST, config.host_header.clone()); - let upstream = "https://api.openai.com/v1/responses"; let upstream_resp = client - .post(upstream) + .post(config.upstream_url.clone()) .headers(headers) .body(body) .send() diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index f3950b540b..df5e2106c7 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -121,7 +121,7 @@ where if total_read == capacity && !saw_newline && !saw_eof { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY is too large to fit in the 512-byte buffer" + "API key is too large to fit in the {BUFFER_SIZE}-byte buffer" )); } @@ -133,7 +133,7 @@ where if total == AUTH_HEADER_PREFIX.len() { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + "API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" )); } @@ -214,7 +214,7 @@ fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { } Err(anyhow!( - "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'" + "API key may only contain ASCII letters, numbers, '-' or '_'" )) } @@ -290,7 +290,9 @@ mod tests { }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); + let expected_error = + format!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer"); + assert!(message.contains(&expected_error)); } #[test] @@ -317,9 +319,7 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } #[test] @@ -337,8 +337,6 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } } From 2eda75a8eee6518d766f5c11d0ec9bd91ef70e1b Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 3 Nov 2025 11:27:45 -0800 Subject: [PATCH 012/122] Do not skip trust prompt on Windows if sandbox is enabled. (#6167) If the experimental windows sandbox is enabled, the trust prompt should show on Windows. --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 38 +++++++++++++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a82cfe0e15..504b37be1d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1469,6 +1469,7 @@ dependencies = [ "regex-lite", "serde", "serde_json", + "serial_test", "shlex", "strum 0.27.2", "strum_macros 0.27.2", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b9bd97ca23..413cc76bf1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -85,6 +85,7 @@ pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use command_safety::is_safe_command; pub use safety::get_platform_sandbox; +pub use safety::set_windows_sandbox_enabled; // Re-export the protocol types from the standalone `codex-protocol` crate so existing // `codex_core::protocol::...` references continue to work across the workspace. pub use codex_protocol::protocol; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 85a3cc5a2a..c56aba0fcf 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -106,3 +106,4 @@ insta = { workspace = true } pretty_assertions = { workspace = true } rand = { workspace = true } vt100 = { workspace = true } +serial_test = { workspace = true } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index eb5d3ae9f3..67df856241 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -16,6 +16,7 @@ use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::find_conversation_path_by_id_str; +use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; @@ -512,8 +513,8 @@ async fn load_config_or_exit( /// or if the current cwd project is already trusted. If not, we need to /// show the trust screen. fn should_show_trust_screen(config: &Config) -> bool { - if cfg!(target_os = "windows") { - // Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely. + if cfg!(target_os = "windows") && get_platform_sandbox().is_none() { + // If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely. return false; } if config.did_user_set_custom_approval_policy_or_sandbox_mode { @@ -557,10 +558,13 @@ mod tests { use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::ProjectConfig; + use codex_core::set_windows_sandbox_enabled; + use serial_test::serial; use tempfile::TempDir; #[test] - fn windows_skips_trust_prompt() -> std::io::Result<()> { + #[serial] + fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -569,6 +573,7 @@ mod tests { )?; config.did_user_set_custom_approval_policy_or_sandbox_mode = false; config.active_project = ProjectConfig { trust_level: None }; + set_windows_sandbox_enabled(false); let should_show = should_show_trust_screen(&config); if cfg!(target_os = "windows") { @@ -584,4 +589,31 @@ mod tests { } Ok(()) } + #[test] + #[serial] + fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + temp_dir.path().to_path_buf(), + )?; + config.did_user_set_custom_approval_policy_or_sandbox_mode = false; + config.active_project = ProjectConfig { trust_level: None }; + set_windows_sandbox_enabled(true); + + let should_show = should_show_trust_screen(&config); + if cfg!(target_os = "windows") { + assert!( + should_show, + "Windows trust prompt should be shown on native Windows with sandbox enabled" + ); + } else { + assert!( + should_show, + "Non-Windows should still show trust prompt when project is untrusted" + ); + } + Ok(()) + } } From 5f3a0473f1d5d1f4ae5f55dfd008627d7b64e840 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:33:34 -0800 Subject: [PATCH 013/122] tui: refine text area word separator handling (#5541) ## Summary - replace the word part enum with a simple `is_word_separator` helper - keep word-boundary logic aligned with the helper and punctuation-aware behavior - extend forward/backward deletion tests to cover whitespace around separators ## Testing - just fix -p codex-tui - cargo test -p codex-tui ------ https://chatgpt.com/codex/tasks/task_i_68f91c71d838832ca2a3c4f0ec1b55d4 --- codex-rs/tui/src/bottom_pane/textarea.rs | 92 ++++++++++++++++++++---- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2571f7bb86..cd913b00d4 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -14,6 +14,12 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +fn is_word_separator(ch: char) -> bool { + WORD_SEPARATORS.contains(ch) +} + #[derive(Debug, Clone)] struct TextElement { range: Range, @@ -841,21 +847,23 @@ impl TextArea { pub(crate) fn beginning_of_previous_word(&self) -> usize { let prefix = &self.text[..self.cursor_pos]; - let Some((first_non_ws_idx, _)) = prefix + let Some((first_non_ws_idx, ch)) = prefix .char_indices() .rev() .find(|&(_, ch)| !ch.is_whitespace()) else { return 0; }; - let before = &prefix[..first_non_ws_idx]; - let candidate = before - .char_indices() - .rev() - .find(|&(_, ch)| ch.is_whitespace()) - .map(|(idx, ch)| idx + ch.len_utf8()) - .unwrap_or(0); - self.adjust_pos_out_of_elements(candidate, true) + let is_separator = is_word_separator(ch); + let mut start = first_non_ws_idx; + for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + start = idx + ch.len_utf8(); + break; + } + start = idx; + } + self.adjust_pos_out_of_elements(start, true) } pub(crate) fn end_of_next_word(&self) -> usize { @@ -864,11 +872,19 @@ impl TextArea { return self.text.len(); }; let word_start = self.cursor_pos + first_non_ws; - let candidate = match self.text[word_start..].find(|c: char| c.is_whitespace()) { - Some(rel_idx) => word_start + rel_idx, - None => self.text.len(), + let mut iter = self.text[word_start..].char_indices(); + let Some((_, first_ch)) = iter.next() else { + return word_start; }; - self.adjust_pos_out_of_elements(candidate, false) + let is_separator = is_word_separator(first_ch); + let mut end = self.text.len(); + for (idx, ch) in iter { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + end = word_start + idx; + break; + } + } + self.adjust_pos_out_of_elements(end, false) } fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { @@ -1239,6 +1255,56 @@ mod tests { assert_eq!(t.cursor(), elem_range.start); } + #[test] + fn delete_backward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "path/to/"); + assert_eq!(t.cursor(), t.text().len()); + + t.delete_backward_word(); + assert_eq!(t.text(), "path/to"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo/ "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo /"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + } + + #[test] + fn delete_forward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "/to/file"); + assert_eq!(t.cursor(), 0); + + t.delete_forward_word(); + assert_eq!(t.text(), "to/file"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("/ foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " foo"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with(" /foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 0); + } + #[test] fn yank_restores_last_kill() { let mut t = ta_with("hello"); From 6ee7fbcfff54bc041cc2418886cfae5a7efb0e08 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 3 Nov 2025 11:44:06 -0800 Subject: [PATCH 014/122] feat: add the time after aborting (#5996) Tell the model how much time passed after the user aborted the call. --- codex-rs/core/src/tools/parallel.rs | 27 +++++++++++++++++------ codex-rs/core/tests/suite/abort_tasks.rs | 28 ++++++++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 7340f5cc9c..baa5321da8 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Instant; use tokio::sync::RwLock; use tokio_util::either::Either; @@ -53,13 +54,16 @@ impl ToolCallRuntime { let turn = Arc::clone(&self.turn_context); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); - let aborted_response = Self::aborted_response(&call); + let started = Instant::now(); let readiness = self.turn_context.tool_call_gate.clone(); let handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { tokio::select! { - _ = cancellation_token.cancelled() => Ok(aborted_response), + _ = cancellation_token.cancelled() => { + let secs = started.elapsed().as_secs_f32().max(0.1); + Ok(Self::aborted_response(&call, secs)) + }, res = async { tracing::info!("waiting for tool gate"); readiness.wait_ready().await; @@ -71,7 +75,7 @@ impl ToolCallRuntime { }; router - .dispatch_tool_call(session, turn, tracker, call) + .dispatch_tool_call(session, turn, tracker, call.clone()) .await } => res, } @@ -91,23 +95,32 @@ impl ToolCallRuntime { } impl ToolCallRuntime { - fn aborted_response(call: &ToolCall) -> ResponseInputItem { + fn aborted_response(call: &ToolCall, secs: f32) -> ResponseInputItem { match &call.payload { ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput { call_id: call.call_id.clone(), - output: "aborted".to_string(), + output: Self::abort_message(call, secs), }, ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { call_id: call.call_id.clone(), - result: Err("aborted".to_string()), + result: Err(Self::abort_message(call, secs)), }, _ => ResponseInputItem::FunctionCallOutput { call_id: call.call_id.clone(), output: FunctionCallOutputPayload { - content: "aborted".to_string(), + content: Self::abort_message(call, secs), ..Default::default() }, }, } } + + fn abort_message(call: &ToolCall, secs: f32) -> String { + match call.tool_name.as_str() { + "shell" | "container.exec" | "local_shell" | "unified_exec" => { + format!("Wall time: {secs:.1} seconds\naborted by user") + } + _ => format!("aborted by user after {secs:.1}s"), + } + } } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c9d595088a..6c03739593 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -1,3 +1,4 @@ +use assert_matches::assert_matches; use std::sync::Arc; use std::time::Duration; @@ -13,6 +14,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_with_timeout; +use regex_lite::Regex; use serde_json::json; /// Integration test: spawn a long‑running shell tool via a mocked Responses SSE @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { ) .await; + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; codex.submit(Op::Interrupt).await.unwrap(); wait_for_event_with_timeout( @@ -159,9 +162,26 @@ async fn interrupt_tool_records_history_entries() { response_mock.saw_function_call(call_id), "function call not recorded in responses payload" ); - assert_eq!( - response_mock.function_call_output_text(call_id).as_deref(), - Some("aborted"), - "aborted function call output not recorded in responses payload" + let output = response_mock + .function_call_output_text(call_id) + .expect("missing function_call_output text"); + let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9])?) seconds\naborted by user$") + .expect("compile regex"); + let captures = re.captures(&output); + assert_matches!( + captures.as_ref(), + Some(caps) if caps.get(1).is_some(), + "aborted message with elapsed seconds" + ); + let secs: f32 = captures + .expect("aborted message with elapsed seconds") + .get(1) + .unwrap() + .as_str() + .parse() + .unwrap(); + assert!( + secs >= 0.1, + "expected at least one tenth of a second of elapsed time, got {secs}" ); } From 07b7d2893703db8286b9da933a39a70ef386d8d3 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 3 Nov 2025 13:12:33 -0800 Subject: [PATCH 015/122] log sandbox commands to $CODEX_HOME instead of cwd (#6171) Logging commands in the Windows Sandbox is temporary, but while we are doing it, let's always write to CODEX_HOME instead of dirtying the cwd. --- codex-rs/cli/src/debug_sandbox.rs | 2 + codex-rs/core/src/exec.rs | 12 +++++- codex-rs/windows-sandbox-rs/src/lib.rs | 10 +++-- codex-rs/windows-sandbox-rs/src/logging.rs | 44 +++++++++++++--------- codex-rs/windows-sandbox-rs/src/process.rs | 3 +- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 889bc5a694..2b90cb932d 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -124,6 +124,7 @@ async fn run_command_under_sandbox( let cwd_clone = cwd.clone(); let env_map = env.clone(); let command_vec = command.clone(); + let base_dir = config.codex_home.clone(); let res = tokio::task::spawn_blocking(move || { run_windows_sandbox_capture( policy_str, @@ -132,6 +133,7 @@ async fn run_command_under_sandbox( &cwd_clone, env_map, None, + Some(base_dir.as_path()), ) }) .await; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index e8eb064498..b4dacd9af6 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -171,6 +171,7 @@ async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, ) -> Result { + use crate::config::find_codex_home; use codex_windows_sandbox::run_windows_sandbox_capture; let ExecParams { @@ -188,8 +189,17 @@ async fn exec_windows_sandbox( }; let sandbox_cwd = cwd.clone(); + let logs_base_dir = find_codex_home().ok(); let spawn_res = tokio::task::spawn_blocking(move || { - run_windows_sandbox_capture(policy_str, &sandbox_cwd, command, &cwd, env, timeout_ms) + run_windows_sandbox_capture( + policy_str, + &sandbox_cwd, + command, + &cwd, + env, + timeout_ms, + logs_base_dir.as_deref(), + ) }) .await; diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index d0b9292dc2..5e165f42dd 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -182,6 +182,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + logs_base_dir: Option<&Path>, ) -> Result { let policy = SandboxPolicy::parse(policy_json_or_preset)?; normalize_null_device_env(&mut env_map); @@ -191,7 +192,7 @@ mod windows_impl { let current_dir = cwd.to_path_buf(); // for now, don't fail if we detect world-writable directories // audit::audit_everyone_writable(¤t_dir, &env_map)?; - log_start(&command); + log_start(&command, logs_base_dir); let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { match &policy.0 { SandboxMode::ReadOnly => { @@ -295,7 +296,7 @@ mod windows_impl { env_block.len(), si.dwFlags, ); - debug_log(&dbg); + debug_log(&dbg, logs_base_dir); unsafe { CloseHandle(in_r); CloseHandle(in_w); @@ -395,9 +396,9 @@ mod windows_impl { }; if exit_code == 0 { - log_success(&command); + log_success(&command, logs_base_dir); } else { - log_failure(&command, &format!("exit code {}", exit_code)); + log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir); } if !persist_aces { @@ -446,6 +447,7 @@ mod stub { _cwd: &Path, _env_map: HashMap, _timeout_ms: Option, + _logs_base_dir: Option<&Path>, ) -> Result { bail!("Windows sandbox is only available on Windows") } diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index feb42ece1a..42d6d80731 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -1,5 +1,7 @@ use std::fs::OpenOptions; use std::io::Write; +use std::path::Path; +use std::path::PathBuf; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; @@ -13,35 +15,43 @@ fn preview(command: &[String]) -> String { } } -fn append_line(line: &str) { - if let Ok(mut f) = OpenOptions::new() - .create(true) - .append(true) - .open(LOG_FILE_NAME) - { - let _ = writeln!(f, "{}", line); +fn log_file_path(base_dir: &Path) -> Option { + if base_dir.is_dir() { + Some(base_dir.join(LOG_FILE_NAME)) + } else { + None } } -pub fn log_start(command: &[String]) { - let p = preview(command); - append_line(&format!("START: {}", p)); +fn append_line(line: &str, base_dir: Option<&Path>) { + if let Some(dir) = base_dir { + if let Some(path) = log_file_path(dir) { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "{}", line); + } + } + } } -pub fn log_success(command: &[String]) { +pub fn log_start(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("SUCCESS: {}", p)); + append_line(&format!("START: {p}"), base_dir); } -pub fn log_failure(command: &[String], detail: &str) { +pub fn log_success(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("FAILURE: {} ({})", p, detail)); + append_line(&format!("SUCCESS: {p}"), base_dir); +} + +pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) { + let p = preview(command); + append_line(&format!("FAILURE: {p} ({detail})"), base_dir); } // Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. -pub fn debug_log(msg: &str) { +pub fn debug_log(msg: &str, base_dir: Option<&Path>) { if std::env::var("SBX_DEBUG").ok().as_deref() == Some("1") { - append_line(&format!("DEBUG: {}", msg)); - eprintln!("{}", msg); + append_line(&format!("DEBUG: {msg}"), base_dir); + eprintln!("{msg}"); } } diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 77c4b8459c..095dcf8b98 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -101,6 +101,7 @@ pub unsafe fn create_process_as_user( argv: &[String], cwd: &Path, env_map: &HashMap, + logs_base_dir: Option<&Path>, ) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { let cmdline_str = argv .iter() @@ -142,7 +143,7 @@ pub unsafe fn create_process_as_user( env_block.len(), si.dwFlags, ); - logging::debug_log(&msg); + logging::debug_log(&msg, logs_base_dir); return Err(anyhow!("CreateProcessAsUserW failed: {}", err)); } Ok((pi, si)) From 1e0e55330424ad4d49fb060fa264c904b7a7f7d0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 3 Nov 2025 16:23:04 -0600 Subject: [PATCH 016/122] Fixed notify handler so it passes correct `input_messages` details (#6143) This fixes bug #6121. The `input_messages` field passed to the notify handler is currently empty because the logic is incorrectly including the OutputText rather than InputText. I've fixed that and added proper filtering to remove messages associated with AGENTS.md and other context injected by the harness. Testing: I wrote a notify handler and verified that the user prompt is correctly passed through to the handler. --- codex-rs/core/src/codex.rs | 15 +++++---------- codex-rs/core/tests/suite/user_notification.rs | 9 +++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1199c6ca84..a2756a7adf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1773,19 +1773,14 @@ pub(crate) async fn run_task( sess.clone_history().await.get_history_for_prompt() }; - let turn_input_messages: Vec = turn_input + let turn_input_messages = turn_input .iter() - .filter_map(|item| match item { - ResponseItem::Message { content, .. } => Some(content), + .filter_map(|item| match parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message), _ => None, }) - .flat_map(|content| { - content.iter().filter_map(|item| match item { - ContentItem::OutputText { text } => Some(text.clone()), - _ => None, - }) - }) - .collect(); + .map(|user_message| user_message.message()) + .collect::>(); match run_turn( Arc::clone(&sess), Arc::clone(&turn_context), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 11deb70f3d..6fc31370c3 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -11,6 +11,9 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; use tempfile::TempDir; use wiremock::matchers::any; @@ -61,6 +64,12 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#, // We fork the notify script, so we need to wait for it to write to the file. fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payload: Value = serde_json::from_str(¬ify_payload_raw)?; + + assert_eq!(payload["type"], json!("agent-turn-complete")); + assert_eq!(payload["input-messages"], json!(["hello world"])); + assert_eq!(payload["last-assistant-message"], json!("Done")); Ok(()) } From e658c6c73bd1725fe96ae5269f66985fa1579603 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 3 Nov 2025 16:11:50 -0800 Subject: [PATCH 017/122] fix: `--search` shouldn't show deprecation message (#6180) Use the new feature flags instead of the old config. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 12 +++++++++--- codex-rs/tui/src/lib.rs | 11 +++++++++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 504b37be1d..4961486e65 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -992,6 +992,7 @@ dependencies = [ "supports-color", "tempfile", "tokio", + "toml", ] [[package]] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index b5c5b33b8c..041644b34d 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -39,6 +39,7 @@ ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } +toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9b2d106c0f..929fd9e785 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -510,15 +510,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { // Respect root-level `-c` overrides plus top-level flags like `--profile`. - let cli_kv_overrides = root_config_overrides + let mut cli_kv_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; + // Honor `--search` via the new feature toggle. + if interactive.web_search { + cli_kv_overrides.push(( + "features.web_search_request".to_string(), + toml::Value::Boolean(true), + )); + } + // Thread through relevant top-level flags (at minimum, `--profile`). - // Also honor `--search` since it maps to a feature toggle. let overrides = ConfigOverrides { config_profile: interactive.config_profile.clone(), - tools_web_search_request: interactive.web_search.then_some(true), ..Default::default() }; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 67df856241..99312ec198 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -94,7 +94,7 @@ use std::io::Write as _; // (tests access modules directly within the crate) pub async fn run_main( - cli: Cli, + mut cli: Cli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { @@ -114,6 +114,13 @@ pub async fn run_main( ) }; + // Map the legacy --search flag to the new feature toggle. + if cli.web_search { + cli.config_overrides + .raw_overrides + .push("features.web_search_request=true".to_string()); + } + // When using `--oss`, let the bootstrapper pick the model (defaulting to // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in // `oss` model provider. @@ -149,7 +156,7 @@ pub async fn run_main( compact_prompt: None, include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), - tools_web_search_request: cli.web_search.then_some(true), + tools_web_search_request: None, experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; From ab63a47173e57c8bdf9ec442964dd0d47007f2dc Mon Sep 17 00:00:00 2001 From: Lucas Freire Sangoi <125471877+sangoi-exe@users.noreply.github.com> Date: Mon, 3 Nov 2025 23:19:26 -0300 Subject: [PATCH 018/122] docs: add example config.toml (#5175) I was missing an example config.toml, and following docs/config.md alone was slower. I had GPT-5 scan the codebase for every accepted config key, check the defaults, and generate a single example config.toml with annotations. It lists all keys Codex reads from TOML, sets each to its effective default where it exists, leaves optional ones commented, and adds short comments on purpose and valid values. This should make onboarding faster and reduce configuration errors. I can rename it to config.example.toml or move it under docs/ if you prefer. --- README.md | 1 + docs/example-config.md | 374 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 docs/example-config.md diff --git a/README.md b/README.md index 5b2b65b0b2..8141610035 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - [**Configuration**](./docs/config.md) + - [Example config](./docs/example-config.md) - [**Sandbox & approvals**](./docs/sandbox.md) - [**Authentication**](./docs/authentication.md) - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced) diff --git a/docs/example-config.md b/docs/example-config.md new file mode 100644 index 0000000000..531a385600 --- /dev/null +++ b/docs/example-config.md @@ -0,0 +1,374 @@ +# Example config.toml + +Use this example configuration as a starting point. For an explanation of each field and additional context, see [Configuration](./config.md). Copy the snippet below to `~/.codex/config.toml` and adjust values as needed. + +```toml +# Codex example configuration (config.toml) +# +# This file lists all keys Codex reads from config.toml, their default values, +# and concise explanations. Values here mirror the effective defaults compiled +# into the CLI. Adjust as needed. +# +# Notes +# - Root keys must appear before tables in TOML. +# - Optional keys that default to "unset" are shown commented out with notes. +# - MCP servers, profiles, and model providers are examples; remove or edit. + +################################################################################ +# Core Model Selection +################################################################################ + +# Primary model used by Codex. Default differs by OS; non-Windows defaults here. +# Linux/macOS default: "gpt-5-codex"; Windows default: "gpt-5". +model = "gpt-5-codex" + +# Model used by the /review feature (code reviews). Default: "gpt-5-codex". +review_model = "gpt-5-codex" + +# Provider id selected from [model_providers]. Default: "openai". +model_provider = "openai" + +# Optional manual model metadata. When unset, Codex auto-detects from model. +# Uncomment to force values. +# model_context_window = 128000 # tokens; default: auto for model +# model_max_output_tokens = 8192 # tokens; default: auto for model +# model_auto_compact_token_limit = 0 # disable/override auto; default: model family specific + +################################################################################ +# Reasoning & Verbosity (Responses API capable models) +################################################################################ + +# Reasoning effort: minimal | low | medium | high (default: medium) +model_reasoning_effort = "medium" + +# Reasoning summary: auto | concise | detailed | none (default: auto) +model_reasoning_summary = "auto" + +# Text verbosity for GPT-5 family (Responses API): low | medium | high (default: medium) +model_verbosity = "medium" + +# Force-enable reasoning summaries for current model (default: false) +model_supports_reasoning_summaries = false + +# Force reasoning summary format: none | experimental (default: none) +model_reasoning_summary_format = "none" + +################################################################################ +# Instruction Overrides +################################################################################ + +# Additional user instructions appended after AGENTS.md. Default: unset. +# developer_instructions = "" + +# Optional legacy base instructions override (prefer AGENTS.md). Default: unset. +# instructions = "" + +# Inline override for the history compaction prompt. Default: unset. +# compact_prompt = "" + +# Override built-in base instructions with a file path. Default: unset. +# experimental_instructions_file = "/absolute/or/relative/path/to/instructions.txt" + +# Load the compact prompt override from a file. Default: unset. +# experimental_compact_prompt_file = "/absolute/or/relative/path/to/compact_prompt.txt" + +################################################################################ +# Approval & Sandbox +################################################################################ + +# When to ask for command approval: +# - untrusted: only known-safe read-only commands auto-run; others prompt +# - on-failure: auto-run in sandbox; prompt only on failure for escalation +# - on-request: model decides when to ask (default) +# - never: never prompt (risky) +approval_policy = "on-request" + +# Filesystem/network sandbox policy for tool calls: +# - read-only (default) +# - workspace-write +# - danger-full-access (no sandbox; extremely risky) +sandbox_mode = "read-only" + +# Extra settings used only when sandbox_mode = "workspace-write". +[sandbox_workspace_write] +# Additional writable roots beyond the workspace (cwd). Default: [] +writable_roots = [] +# Allow outbound network access inside the sandbox. Default: false +network_access = false +# Exclude $TMPDIR from writable roots. Default: false +exclude_tmpdir_env_var = false +# Exclude /tmp from writable roots. Default: false +exclude_slash_tmp = false + +################################################################################ +# Shell Environment Policy for spawned processes +################################################################################ + +[shell_environment_policy] +# inherit: all (default) | core | none +inherit = "all" +# Skip default excludes for names containing KEY/TOKEN (case-insensitive). Default: false +ignore_default_excludes = false +# Case-insensitive glob patterns to remove (e.g., "AWS_*", "AZURE_*"). Default: [] +exclude = [] +# Explicit key/value overrides (always win). Default: {} +set = {} +# Whitelist; if non-empty, keep only matching vars. Default: [] +include_only = [] +# Experimental: run via user shell profile. Default: false +experimental_use_profile = false + +################################################################################ +# History & File Opener +################################################################################ + +[history] +# save-all (default) | none +persistence = "save-all" +# Maximum bytes for history file (currently not enforced). Example: 5242880 +# max_bytes = 0 + +# URI scheme for clickable citations: vscode (default) | vscode-insiders | windsurf | cursor | none +file_opener = "vscode" + +################################################################################ +# UI, Notifications, and Misc +################################################################################ + +[tui] +# Desktop notifications from the TUI: boolean or filtered list. Default: false +# Examples: true | ["agent-turn-complete", "approval-requested"] +notifications = false + +# Suppress internal reasoning events from output (default: false) +hide_agent_reasoning = false + +# Show raw reasoning content when available (default: false) +show_raw_agent_reasoning = false + +# Disable burst-paste detection in the TUI (default: false) +disable_paste_burst = false + +# Track Windows onboarding acknowledgement (Windows only). Default: false +windows_wsl_setup_acknowledged = false + +# External notifier program (argv array). When unset: disabled. +# Example: notify = ["notify-send", "Codex"] +# notify = [ ] + +# In-product notices (mostly set automatically by Codex). +[notice] +# hide_full_access_warning = true + +################################################################################ +# Authentication & Login +################################################################################ + +# Where to persist CLI login credentials: file (default) | keyring | auto +cli_auth_credentials_store = "file" + +# Base URL for ChatGPT auth flow (not OpenAI API). Default: +chatgpt_base_url = "https://chatgpt.com/backend-api/" + +# Restrict ChatGPT login to a specific workspace id. Default: unset. +# forced_chatgpt_workspace_id = "" + +# Force login mechanism when Codex would normally auto-select. Default: unset. +# Allowed values: chatgpt | api +# forced_login_method = "chatgpt" + +################################################################################ +# Project Documentation Controls +################################################################################ + +# Max bytes from AGENTS.md to embed into first-turn instructions. Default: 32768 +project_doc_max_bytes = 32768 + +# Ordered fallbacks when AGENTS.md is missing at a directory level. Default: [] +project_doc_fallback_filenames = [] + +################################################################################ +# Tools (legacy toggles kept for compatibility) +################################################################################ + +[tools] +# Enable web search tool (alias: web_search_request). Default: false +web_search = false + +# Enable the view_image tool so the agent can attach local images. Default: true +view_image = true + +# (Alias accepted) You can also write: +# web_search_request = false + +################################################################################ +# Centralized Feature Flags (preferred) +################################################################################ + +[features] +# Leave this table empty to accept defaults. Set explicit booleans to opt in/out. +unified_exec = false +streamable_shell = false +rmcp_client = false +apply_patch_freeform = false +view_image_tool = true +web_search_request = false +experimental_sandbox_command_assessment = false +ghost_commit = false +enable_experimental_windows_sandbox = false + +################################################################################ +# Experimental toggles (legacy; prefer [features]) +################################################################################ + +# Use experimental exec command tool (streamable shell). Default: false +experimental_use_exec_command_tool = false + +# Use experimental unified exec tool. Default: false +experimental_use_unified_exec_tool = false + +# Use experimental Rust MCP client (enables OAuth for HTTP MCP). Default: false +experimental_use_rmcp_client = false + +# Include apply_patch via freeform editing path (affects default tool set). Default: false +experimental_use_freeform_apply_patch = false + +# Enable model-based sandbox command assessment. Default: false +experimental_sandbox_command_assessment = false + +################################################################################ +# MCP (Model Context Protocol) servers +################################################################################ + +# Preferred store for MCP OAuth credentials: auto (default) | file | keyring +mcp_oauth_credentials_store = "auto" + +# Define MCP servers under this table. Leave empty to disable. +[mcp_servers] + +# --- Example: STDIO transport --- +# [mcp_servers.docs] +# command = "docs-server" # required +# args = ["--port", "4000"] # optional +# env = { "API_KEY" = "value" } # optional key/value pairs copied as-is +# env_vars = ["ANOTHER_SECRET"] # optional: forward these from the parent env +# cwd = "/path/to/server" # optional working directory override +# startup_timeout_sec = 10.0 # optional; default 10.0 seconds +# # startup_timeout_ms = 10000 # optional alias for startup timeout (milliseconds) +# tool_timeout_sec = 60.0 # optional; default 60.0 seconds +# enabled_tools = ["search", "summarize"] # optional allow-list +# disabled_tools = ["slow-tool"] # optional deny-list (applied after allow-list) + +# --- Example: Streamable HTTP transport --- +# [mcp_servers.github] +# url = "https://github-mcp.example.com/mcp" # required +# bearer_token_env_var = "GITHUB_TOKEN" # optional; Authorization: Bearer +# http_headers = { "X-Example" = "value" } # optional static headers +# env_http_headers = { "X-Auth" = "AUTH_ENV" } # optional headers populated from env vars +# startup_timeout_sec = 10.0 # optional +# tool_timeout_sec = 60.0 # optional +# enabled_tools = ["list_issues"] # optional allow-list + +################################################################################ +# Model Providers (extend/override built-ins) +################################################################################ + +# Built-ins include: +# - openai (Responses API; requires login or OPENAI_API_KEY via auth flow) +# - oss (Chat Completions API; defaults to http://localhost:11434/v1) + +[model_providers] + +# --- Example: override OpenAI with explicit base URL or headers --- +# [model_providers.openai] +# name = "OpenAI" +# base_url = "https://api.openai.com/v1" # default if unset +# wire_api = "responses" # "responses" | "chat" (default varies) +# # requires_openai_auth = true # built-in OpenAI defaults to true +# # request_max_retries = 4 # default 4; max 100 +# # stream_max_retries = 5 # default 5; max 100 +# # stream_idle_timeout_ms = 300000 # default 300_000 (5m) +# # experimental_bearer_token = "sk-example" # optional dev-only direct bearer token +# # http_headers = { "X-Example" = "value" } +# # env_http_headers = { "OpenAI-Organization" = "OPENAI_ORGANIZATION", "OpenAI-Project" = "OPENAI_PROJECT" } + +# --- Example: Azure (Chat/Responses depending on endpoint) --- +# [model_providers.azure] +# name = "Azure" +# base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" +# wire_api = "responses" # or "chat" per endpoint +# query_params = { api-version = "2025-04-01-preview" } +# env_key = "AZURE_OPENAI_API_KEY" +# # env_key_instructions = "Set AZURE_OPENAI_API_KEY in your environment" + +# --- Example: Local OSS (e.g., Ollama-compatible) --- +# [model_providers.ollama] +# name = "Ollama" +# base_url = "http://localhost:11434/v1" +# wire_api = "chat" + +################################################################################ +# Profiles (named presets) +################################################################################ + +# Active profile name. When unset, no profile is applied. +# profile = "default" + +[profiles] + +# [profiles.default] +# model = "gpt-5-codex" +# model_provider = "openai" +# approval_policy = "on-request" +# sandbox_mode = "read-only" +# model_reasoning_effort = "medium" +# model_reasoning_summary = "auto" +# model_verbosity = "medium" +# chatgpt_base_url = "https://chatgpt.com/backend-api/" +# experimental_compact_prompt_file = "compact_prompt.txt" +# include_apply_patch_tool = false +# experimental_use_unified_exec_tool = false +# experimental_use_exec_command_tool = false +# experimental_use_rmcp_client = false +# experimental_use_freeform_apply_patch = false +# experimental_sandbox_command_assessment = false +# tools_web_search = false +# tools_view_image = true +# features = { unified_exec = false } + +################################################################################ +# Projects (trust levels) +################################################################################ + +# Mark specific worktrees as trusted. Only "trusted" is recognized. +[projects] +# [projects."/absolute/path/to/project"] +# trust_level = "trusted" + +################################################################################ +# OpenTelemetry (OTEL) – disabled by default +################################################################################ + +[otel] +# Include user prompt text in logs. Default: false +log_user_prompt = false +# Environment label applied to telemetry. Default: "dev" +environment = "dev" +# Exporter: none (default) | otlp-http | otlp-grpc +exporter = "none" + +# Example OTLP/HTTP exporter configuration +# [otel] +# exporter = { otlp-http = { +# endpoint = "https://otel.example.com/v1/logs", +# protocol = "binary", # "binary" | "json" +# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } +# }} + +# Example OTLP/gRPC exporter configuration +# [otel] +# exporter = { otlp-grpc = { +# endpoint = "https://otel.example.com:4317", +# headers = { "x-otlp-meta" = "abc123" } +# }} +``` From 553db8def1f0c05b3164d3188ecba943fad32b74 Mon Sep 17 00:00:00 2001 From: Ricardo Ander-Egg <37962604+polyrand@users.noreply.github.com> Date: Tue, 4 Nov 2025 05:28:33 +0100 Subject: [PATCH 019/122] Follow symlinks during file search (#4453) I have read the CLA Document and I hereby sign the CLA Closes #4452 This fixes a usability issue where users with symlinked folders in their working directory couldn't search those files using the `@` file search feature. ## Rationale The "bug" was in the file search implementation in `codex-rs/file-search/src/lib.rs`. The `WalkBuilder` was using default settings which don't follow symlinks, causing two related issues: 1. Partial search results: The `@` search would find symlinked directories but couldn't find files inside them 2. Inconsistent behavior: Users expect symlinked folders to behave like regular folders in search results. ## Root cause The `ignore` crate's `WalkBuilder` defaults to `.follow_links(false)` [[source](https://github.com/BurntSushi/ripgrep/blob/9802945e6342ec284633924cb7d8d3ce67204995/crates/ignore/src/walk.rs#L532)], so when traversing the file system, it would: - Detect symlinked directories as directory entries - But not traverse into them to index their contents - The `get_file_path` function would then filter out actual directories, leaving only the symlinked folder itself as a result Fix: Added `.follow_links(true)` to the `WalkBuilder` configuration, making the file search follow symlinks and index their contents just like regular directories. This change maintains backward compatibility since symlink following is generally expected behavior for file search tools, and it aligns with how users expect the `@` search feature to work. Co-authored-by: Eric Traut --- codex-rs/file-search/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 0ae512102a..0afc9ea6a2 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -159,6 +159,8 @@ pub fn run( .threads(num_walk_builder_threads) // Allow hidden entries. .hidden(false) + // Follow symlinks to search their contents. + .follow_links(true) // Don't require git to be present to apply to apply git-related ignore rules. .require_git(false); if !respect_gitignore { From dc2f26f7b526901c2f07405dc7e8e01a80ec062a Mon Sep 17 00:00:00 2001 From: Robby He <448523760@qq.com> Date: Tue, 4 Nov 2025 12:55:41 +0800 Subject: [PATCH 020/122] Fix is_api_message to correctly exclude reasoning messages (#6156) ## Problem The `is_api_message` function in `conversation_history.rs` had a misalignment between its documentation and implementation: - **Comment stated**: "Anything that is not a system message or 'reasoning' message is considered an API message" - **Code behavior**: Was returning `true` for `ResponseItem::Reasoning`, meaning reasoning messages were incorrectly treated as API messages This inconsistency could lead to reasoning messages being persisted in conversation history when they should be filtered out. ## Root Cause Investigation revealed that reasoning messages are explicitly excluded throughout the codebase: 1. **Chat completions API** (lines 267-272 in `chat_completions.rs`) omits reasoning from conversation history: ```rust ResponseItem::Reasoning { .. } | ResponseItem::Other => { // Omit these items from the conversation history. continue; } ``` 2. **Existing tests** like `drops_reasoning_when_last_role_is_user` and `ignores_reasoning_before_last_user` validate that reasoning should be excluded from API payloads ## Solution Fixed the `is_api_message` function to align with its documentation and the rest of the codebase: ```rust // Before: Reasoning was incorrectly returning true ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } => true, // After: Reasoning correctly returns false ResponseItem::WebSearchCall { .. } => true, ResponseItem::Reasoning { .. } | ResponseItem::Other => false, ``` ## Testing - Enhanced existing test to verify reasoning messages are properly filtered out - All 264 core tests pass, including 8 chat completions tests that validate reasoning behavior - No regressions introduced This ensures reasoning messages are consistently excluded from API message processing across the entire codebase. --- codex-rs/core/src/conversation_history.rs | 34 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 9a5570a249..ef493f1458 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -516,8 +516,8 @@ fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String { result } -/// Anything that is not a system message or "reasoning" message is considered -/// an API message. +/// API messages include every non-system item (user/assistant messages, reasoning, +/// tool calls, tool outputs, shell calls, and web-search calls). fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", @@ -542,6 +542,8 @@ mod tests { use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; + use codex_protocol::models::ReasoningItemContent; + use codex_protocol::models::ReasoningItemReasoningSummary; use pretty_assertions::assert_eq; fn assistant_msg(text: &str) -> ResponseItem { @@ -570,10 +572,23 @@ mod tests { } } + fn reasoning_msg(text: &str) -> ResponseItem { + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: text.to_string(), + }]), + encrypted_content: None, + } + } + #[test] fn filters_non_api_messages() { let mut h = ConversationHistory::default(); - // System message is not an API message; Other is ignored. + // System message is not API messages; Other is ignored. let system = ResponseItem::Message { id: None, role: "system".to_string(), @@ -581,7 +596,8 @@ mod tests { text: "ignored".to_string(), }], }; - h.record_items([&system, &ResponseItem::Other]); + let reasoning = reasoning_msg("thinking..."); + h.record_items([&system, &reasoning, &ResponseItem::Other]); // User and assistant should be retained. let u = user_msg("hi"); @@ -592,6 +608,16 @@ mod tests { assert_eq!( items, vec![ + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: "thinking...".to_string(), + }]), + encrypted_content: None, + }, ResponseItem::Message { id: None, role: "user".to_string(), From d3187dbc17a9a642904a115d2fa2d99ac0a12197 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 3 Nov 2025 22:01:33 -0800 Subject: [PATCH 021/122] [App-server] v2 for account/updated and account/logout (#6175) V2 for `account/updated` and `account/logout` for app server. correspond to old `authStatusChange` and `LogoutChatGpt` respectively. Followup PRs will make other v2 endpoints call `account/updated` instead of `authStatusChange` too. --- codex-rs/app-server-protocol/src/export.rs | 1 + .../src/protocol/common.rs | 6 ++ .../app-server-protocol/src/protocol/v1.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 7 ++ .../app-server/src/codex_message_processor.rs | 78 ++++++++++----- codex-rs/app-server/src/outgoing_message.rs | 22 +++++ .../app-server/tests/common/mcp_process.rs | 5 + codex-rs/app-server/tests/suite/mod.rs | 1 + codex-rs/app-server/tests/suite/v2/account.rs | 99 +++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 2 + codex-rs/core/tests/common/lib.rs | 4 +- 11 files changed, 200 insertions(+), 26 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/account.rs create mode 100644 codex-rs/app-server/tests/suite/v2/mod.rs diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index c8facffdb5..3affa7afca 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -44,6 +44,7 @@ macro_rules! for_each_schema_type { $macro!(crate::ArchiveConversationParams); $macro!(crate::ArchiveConversationResponse); $macro!(crate::AuthMode); + $macro!(crate::AccountUpdatedNotification); $macro!(crate::AuthStatusChangeNotification); $macro!(crate::CancelLoginChatGptParams); $macro!(crate::CancelLoginChatGptResponse); diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dcab5eaed1..efc60830c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -372,6 +372,11 @@ pub struct FuzzyFileSearchResponse { #[strum(serialize_all = "camelCase")] pub enum ServerNotification { /// NEW NOTIFICATIONS + #[serde(rename = "account/updated")] + #[ts(rename = "account/updated")] + #[strum(serialize = "account/updated")] + AccountUpdated(v2::AccountUpdatedNotification), + #[serde(rename = "account/rateLimits/updated")] #[ts(rename = "account/rateLimits/updated")] #[strum(serialize = "account/rateLimits/updated")] @@ -391,6 +396,7 @@ pub enum ServerNotification { impl ServerNotification { pub fn to_params(self) -> Result { match self { + ServerNotification::AccountUpdated(params) => serde_json::to_value(params), ServerNotification::AccountRateLimitsUpdated(params) => serde_json::to_value(params), ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 8fbcf6b7f3..252fb0410a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -400,6 +400,7 @@ pub struct SessionConfiguredNotification { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +/// Deprecated notification. Use AccountUpdatedNotification instead. pub struct AuthStatusChangeNotification { pub auth_method: Option, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ccd89e6a1f..8c39628412 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1,3 +1,4 @@ +use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; @@ -120,3 +121,9 @@ pub struct UploadFeedbackParams { pub struct UploadFeedbackResponse { pub thread_id: String, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AccountUpdatedNotification { + pub auth_method: Option, +} diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7cd60eaeda..8ea4cef8ba 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; use codex_app_server_protocol::ApplyPatchApprovalParams; @@ -200,8 +201,7 @@ impl CodexMessageProcessor { request_id, params: _, } => { - self.send_unimplemented_error(request_id, "account/logout") - .await; + self.logout_v2(request_id).await; } ClientRequest::GetAccount { request_id, @@ -250,7 +250,7 @@ impl CodexMessageProcessor { request_id, params: _, } => { - self.logout_chatgpt(request_id).await; + self.logout_v1(request_id).await; } ClientRequest::GetAuthStatus { request_id, params } => { self.get_auth_status(request_id, params).await; @@ -494,9 +494,9 @@ impl CodexMessageProcessor { } } - async fn logout_chatgpt(&mut self, request_id: RequestId) { + async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. { - // Cancel any active login attempt. let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { active.drop(); @@ -504,31 +504,61 @@ impl CodexMessageProcessor { } if let Err(err) = self.auth_manager.logout() { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("logout failed: {err}"), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::LogoutChatGptResponse {}, - ) - .await; + // Reflect the current auth method after logout (likely None). + Ok(self.auth_manager.auth().map(|auth| auth.mode)) + } - // Send auth status change notification reflecting the current auth mode - // after logout. - let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - self.outgoing - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; + async fn logout_v1(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response( + request_id, + codex_app_server_protocol::LogoutChatGptResponse {}, + ) + .await; + + let payload = AuthStatusChangeNotification { + auth_method: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn logout_v2(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response( + request_id, + codex_app_server_protocol::LogoutAccountResponse {}, + ) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_method: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } } async fn get_auth_status( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3f4497fb23..3d56c9bd83 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -141,6 +141,8 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { + use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; @@ -204,4 +206,24 @@ mod tests { "ensure the notification serializes correctly" ); } + + #[test] + fn verify_account_updated_notification_serialization() { + let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { + auth_method: Some(AuthMode::ApiKey), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/updated", + "params": { + "authMethod": "apikey" + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 90c7645d58..3c1fbf777c 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -321,6 +321,11 @@ impl McpProcess { self.send_request("logoutChatGpt", None).await } + /// Send an `account/logout` JSON-RPC request. + pub async fn send_logout_account_request(&mut self) -> anyhow::Result { + self.send_request("account/logout", None).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index c8763cd0c9..ce8b69a030 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -13,3 +13,4 @@ mod send_message; mod set_default_model; mod user_agent; mod user_info; +mod v2; diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs new file mode 100644 index 0000000000..5cc3719f24 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_login::login_with_api_key; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +// Helper to create a minimal config.toml for the app server +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:0/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"#, + ) +} + +#[tokio::test] +async fn logout_account_removes_auth_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + )?; + assert!(codex_home.path().join("auth.json").exists()); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let id = mcp.send_logout_account_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(id)), + ) + .await??; + let _ok: LogoutAccountResponse = to_response(resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert!( + payload.auth_method.is_none(), + "auth_method should be None after logout" + ); + + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be deleted" + ); + + let status_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + let status_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(status_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(status_resp)?; + assert_eq!(status.auth_method, None); + assert_eq!(status.auth_token, None); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs new file mode 100644 index 0000000000..bd7a1467cd --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -0,0 +1,2 @@ +// v2 test suite modules +mod account; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0064d0e5a4..3f75ed1819 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -244,7 +244,7 @@ pub mod fs_wait { if path.exists() { Ok(path) } else { - Err(anyhow!("timed out waiting for {:?}", path)) + Err(anyhow!("timed out waiting for {path:?}")) } } @@ -284,7 +284,7 @@ pub mod fs_wait { if let Some(found) = scan_for_match(&root, predicate) { Ok(found) } else { - Err(anyhow!("timed out waiting for matching file in {:?}", root)) + Err(anyhow!("timed out waiting for matching file in {root:?}")) } } From 7e068e10941d46bbe059aac92db5c82eb3d8bd72 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 4 Nov 2025 08:27:16 -0800 Subject: [PATCH 022/122] fix: ignore reasoning deltas because we send it with turn item (#6202) should fix this: image --- codex-rs/core/src/tasks/review.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 57258f4cd9..e56b9bf7f6 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -10,6 +10,8 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ReasoningContentDeltaEvent; +use codex_protocol::protocol::ReasoningRawContentDeltaEvent; use codex_protocol::protocol::ReviewOutputEvent; use tokio_util::sync::CancellationToken; @@ -122,7 +124,9 @@ async fn process_review_events( .. }) | EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { .. }) - | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) => {} + | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) + | EventMsg::ReasoningContentDelta(ReasoningContentDeltaEvent { .. }) + | EventMsg::ReasoningRawContentDelta(ReasoningRawContentDeltaEvent { .. }) => {} EventMsg::TaskComplete(task_complete) => { // Parse review output from the last agent message (if present). let out = task_complete From cb6584de46f27e349d6ebe4dbe3e3a46fca65a45 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 4 Nov 2025 09:17:16 -0800 Subject: [PATCH 023/122] fix: pin musl 1.2.5 for DNS fixes (#6189) ## Summary musl 1.2.5 includes [several fixes to DNS over TCP](https://www.openwall.com/lists/musl/2024/03/01/2), which appears to be the root cause of #6116. This approach is a bit janky, but according to codex: > On the Ubuntu 24.04 runners we use, apt-cache policy musl-tools shows only the distro build (1.2.4-2ubuntu2)" We should build with this version and confirm. ## Testing - [ ] TODO: test and see if this fixes Azure issues --- .github/actions/setup-musl-1_2_5/action.yml | 47 +++++++++++++++++++++ .github/workflows/rust-ci.yml | 12 ++---- .github/workflows/rust-release.yml | 8 ++-- 3 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 .github/actions/setup-musl-1_2_5/action.yml diff --git a/.github/actions/setup-musl-1_2_5/action.yml b/.github/actions/setup-musl-1_2_5/action.yml new file mode 100644 index 0000000000..64d4320480 --- /dev/null +++ b/.github/actions/setup-musl-1_2_5/action.yml @@ -0,0 +1,47 @@ +name: Setup musl 1.2.5 toolchain +description: Install musl 1.2.5 from source and configure the linker for the requested target. +inputs: + target: + description: Cargo target triple that requires musl (e.g., x86_64-unknown-linux-musl). + required: true +runs: + using: composite + steps: + - name: Install musl 1.2.5 + shell: bash + env: + MUSL_VERSION: 1.2.5 + MUSL_PREFIX: /opt/musl-1.2.5 + DEBIAN_FRONTEND: noninteractive + run: | + set -euo pipefail + sudo apt-get -y update -o Acquire::Retries=3 + sudo apt-get -y install --no-install-recommends build-essential curl pkg-config + + curl -sSfL --retry 3 --retry-delay 1 "https://musl.libc.org/releases/musl-${MUSL_VERSION}.tar.gz" -o /tmp/musl.tar.gz + tar -xf /tmp/musl.tar.gz -C /tmp + + pushd "/tmp/musl-${MUSL_VERSION}" + ./configure --prefix="${MUSL_PREFIX}" + make -j"$(nproc)" + sudo make install + popd + + echo "${MUSL_PREFIX}/bin" >> "$GITHUB_PATH" + musl_gcc="${MUSL_PREFIX}/bin/musl-gcc" + "${musl_gcc}" --version + + case "${{ inputs.target }}" in + x86_64-unknown-linux-musl) + echo "CC_x86_64_unknown_linux_musl=${musl_gcc}" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=${musl_gcc}" >> "$GITHUB_ENV" + ;; + aarch64-unknown-linux-musl) + echo "CC_aarch64_unknown_linux_musl=${musl_gcc}" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=${musl_gcc}" >> "$GITHUB_ENV" + ;; + *) + echo "Unsupported musl target '${{ inputs.target }}'" >&2 + exit 1 + ;; + esac diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 103e8cf6bd..6bc7574dae 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -217,14 +217,10 @@ jobs: key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools - env: - DEBIAN_FRONTEND: noninteractive - shell: bash - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends musl-tools pkg-config + name: Setup musl 1.2.5 toolchain + uses: ./.github/actions/setup-musl-1_2_5 + with: + target: ${{ matrix.target }} - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 26afdaf552..f58c84bd9e 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -92,10 +92,10 @@ jobs: key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + name: Setup musl 1.2.5 toolchain + uses: ./.github/actions/setup-musl-1_2_5 + with: + target: ${{ matrix.target }} - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy From fe54c216a39d1237caa0c586ef67e8f3ae08384b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 4 Nov 2025 11:21:35 -0800 Subject: [PATCH 024/122] ignore deltas in `codex_delegate` (#6208) ignore legacy deltas in codex-delegate to avoid this [issue](https://github.com/openai/codex/pull/6202). --- codex-rs/core/src/codex_delegate.rs | 5 ++ codex-rs/core/src/tasks/review.rs | 6 +-- codex-rs/core/tests/suite/codex_delegate.rs | 52 +++++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index c0fa0bf780..4cb4d4a06a 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -158,6 +158,11 @@ async fn forward_events( ) { while let Ok(event) = codex.next_event().await { match event { + // ignore all legacy delta events + Event { + id: _, + msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_), + } => continue, Event { id: _, msg: EventMsg::SessionConfigured(_), diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index e56b9bf7f6..57258f4cd9 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -10,8 +10,6 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemCompletedEvent; -use codex_protocol::protocol::ReasoningContentDeltaEvent; -use codex_protocol::protocol::ReasoningRawContentDeltaEvent; use codex_protocol::protocol::ReviewOutputEvent; use tokio_util::sync::CancellationToken; @@ -124,9 +122,7 @@ async fn process_review_events( .. }) | EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { .. }) - | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) - | EventMsg::ReasoningContentDelta(ReasoningContentDeltaEvent { .. }) - | EventMsg::ReasoningRawContentDelta(ReasoningRawContentDeltaEvent { .. }) => {} + | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) => {} EventMsg::TaskComplete(task_complete) => { // Parse review output from the last agent message (if present). let out = task_complete diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 82a7bd9abc..c6ece7fe56 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -8,6 +8,8 @@ use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_reasoning_item_added; +use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -15,6 +17,7 @@ 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; +use pretty_assertions::assert_eq; /// Delegate should surface ExecApprovalRequest from sub-agent and proceed /// after parent submits an approval decision. @@ -171,3 +174,52 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { .await; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_delegate_ignores_legacy_deltas() { + skip_if_no_network!(); + + // Single response with reasoning summary deltas. + let sse_stream = sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item_added("reason-1", &["initial"]), + ev_reasoning_summary_text_delta("think-1"), + ev_completed("resp-1"), + ]); + + let server = start_mock_server().await; + mount_sse_sequence(&server, vec![sse_stream]).await; + + let mut builder = test_codex(); + let test = builder.build(&server).await.expect("build test codex"); + + // Kick off review (delegated). + test.codex + .submit(Op::Review { + review_request: ReviewRequest { + prompt: "Please review".to_string(), + user_facing_hint: "review".to_string(), + }, + }) + .await + .expect("submit review"); + + let mut reasoning_delta_count = 0; + let mut legacy_reasoning_delta_count = 0; + + loop { + let ev = wait_for_event(&test.codex, |_| true).await; + match ev { + EventMsg::ReasoningContentDelta(_) => reasoning_delta_count += 1, + EventMsg::AgentReasoningDelta(_) => legacy_reasoning_delta_count += 1, + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + assert_eq!(reasoning_delta_count, 1, "expected one new reasoning delta"); + assert_eq!( + legacy_reasoning_delta_count, 1, + "expected one legacy reasoning delta" + ); +} From 3a22018eddb5140021fc6c6952f87fb3240f4cd7 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 4 Nov 2025 11:56:40 -0800 Subject: [PATCH 025/122] Revert "fix: pin musl 1.2.5 for DNS fixes" (#6222) Reverts openai/codex#6189 --- .github/actions/setup-musl-1_2_5/action.yml | 47 --------------------- .github/workflows/rust-ci.yml | 12 ++++-- .github/workflows/rust-release.yml | 8 ++-- 3 files changed, 12 insertions(+), 55 deletions(-) delete mode 100644 .github/actions/setup-musl-1_2_5/action.yml diff --git a/.github/actions/setup-musl-1_2_5/action.yml b/.github/actions/setup-musl-1_2_5/action.yml deleted file mode 100644 index 64d4320480..0000000000 --- a/.github/actions/setup-musl-1_2_5/action.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Setup musl 1.2.5 toolchain -description: Install musl 1.2.5 from source and configure the linker for the requested target. -inputs: - target: - description: Cargo target triple that requires musl (e.g., x86_64-unknown-linux-musl). - required: true -runs: - using: composite - steps: - - name: Install musl 1.2.5 - shell: bash - env: - MUSL_VERSION: 1.2.5 - MUSL_PREFIX: /opt/musl-1.2.5 - DEBIAN_FRONTEND: noninteractive - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends build-essential curl pkg-config - - curl -sSfL --retry 3 --retry-delay 1 "https://musl.libc.org/releases/musl-${MUSL_VERSION}.tar.gz" -o /tmp/musl.tar.gz - tar -xf /tmp/musl.tar.gz -C /tmp - - pushd "/tmp/musl-${MUSL_VERSION}" - ./configure --prefix="${MUSL_PREFIX}" - make -j"$(nproc)" - sudo make install - popd - - echo "${MUSL_PREFIX}/bin" >> "$GITHUB_PATH" - musl_gcc="${MUSL_PREFIX}/bin/musl-gcc" - "${musl_gcc}" --version - - case "${{ inputs.target }}" in - x86_64-unknown-linux-musl) - echo "CC_x86_64_unknown_linux_musl=${musl_gcc}" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=${musl_gcc}" >> "$GITHUB_ENV" - ;; - aarch64-unknown-linux-musl) - echo "CC_aarch64_unknown_linux_musl=${musl_gcc}" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=${musl_gcc}" >> "$GITHUB_ENV" - ;; - *) - echo "Unsupported musl target '${{ inputs.target }}'" >&2 - exit 1 - ;; - esac diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 6bc7574dae..103e8cf6bd 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -217,10 +217,14 @@ jobs: key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Setup musl 1.2.5 toolchain - uses: ./.github/actions/setup-musl-1_2_5 - with: - target: ${{ matrix.target }} + name: Install musl build tools + env: + DEBIAN_FRONTEND: noninteractive + shell: bash + run: | + set -euo pipefail + sudo apt-get -y update -o Acquire::Retries=3 + sudo apt-get -y install --no-install-recommends musl-tools pkg-config - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f58c84bd9e..26afdaf552 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -92,10 +92,10 @@ jobs: key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Setup musl 1.2.5 toolchain - uses: ./.github/actions/setup-musl-1_2_5 - with: - target: ${{ matrix.target }} + name: Install musl build tools + run: | + sudo apt-get update + sudo apt-get install -y musl-tools pkg-config - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy From d40a6b7f73ed2714f9e869b19c764fc2eecb8b3f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 4 Nov 2025 13:02:27 -0800 Subject: [PATCH 026/122] fix: Update the deprecation message to link to the docs (#6211) The deprecation message is currently a bit confusing. Users may not understand what is `[features].x`. I updated the docs and the deprecation message for more guidance. --------- Co-authored-by: Gabriel Peal --- codex-rs/cli/src/mcp_cmd.rs | 4 ++- codex-rs/core/src/codex.rs | 2 +- .../core/tests/suite/deprecation_notice.rs | 2 +- docs/config.md | 31 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index be08cfb683..ec37c3a6bd 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -353,7 +353,9 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) .context("failed to load configuration")?; if !config.features.enabled(Feature::RmcpClient) { - bail!("OAuth login is only supported when [features].rmcp_client is true in config.toml."); + bail!( + "OAuth login is only supported when [features].rmcp_client is true in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." + ); } let LoginArgs { name, scopes } = login_args; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a2756a7adf..b2ab5c1022 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -553,7 +553,7 @@ impl Session { None } else { Some(format!( - "You can either enable it using the CLI with `--enable {canonical}` or through the config.toml file with `[features].{canonical}`" + "Enable it with `--enable {canonical}` or `[features].{canonical}` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." )) }; post_session_configured_events.push(Event { diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index a2cdcf4657..6842bd4d53 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -43,7 +43,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() assert_eq!( details.as_deref(), Some( - "You can either enable it using the CLI with `--enable streamable_shell` or through the config.toml file with `[features].streamable_shell`" + "Enable it with `--enable streamable_shell` or `[features].streamable_shell` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." ), ); diff --git a/docs/config.md b/docs/config.md index e3505e3d49..1175a986c4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -4,6 +4,7 @@ Codex configuration gives you fine-grained control over the model, execution env ## Quick navigation +- [Feature flags](#feature-flags) - [Model selection](#model-selection) - [Execution environment](#execution-environment) - [MCP integration](#mcp-integration) @@ -26,6 +27,36 @@ Codex supports several mechanisms for setting config values: Both the `--config` flag and the `config.toml` file support the following options: +## Feature flags + +Optional and experimental capabilities are toggled via the `[features]` table in `$CODEX_HOME/config.toml`. If you see a deprecation notice mentioning a legacy key (for example `experimental_use_exec_command_tool`), move the setting into `[features]` or pass `--enable `. + +```toml +[features] +streamable_shell = true # enable the streamable exec tool +web_search_request = true # allow the model to request web searches +# view_image_tool defaults to true; omit to keep defaults +``` + +Supported features: + +| Key | Default | Stage | Description | +| ----------------------------------------- | :-----: | ------------ | ---------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `streamable_shell` | false | Experimental | Use the streamable exec-command/write-stdin pair | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | + +Notes: + +- Omit a key to accept its default. +- Legacy booleans such as `experimental_use_exec_command_tool`, `experimental_use_unified_exec_tool`, `include_apply_patch_tool`, and similar `experimental_use_*` keys are deprecated; setting the corresponding `[features].` avoids repeated warnings. + ## Model selection ### model From edf4c3f627de52f0774c2e5e2dcce2b4fd6a480e Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 4 Nov 2025 17:02:39 -0800 Subject: [PATCH 027/122] [app-server] feat: export.rs supports a v2 namespace, initial v2 notifications (#6212) **Typescript and JSON schema exports** While working on Thread/Turn/Items type definitions, I realize we will run into name conflicts between v1 and v2 APIs (e.g. `RateLimitWindow` which won't be reusable since v1 uses `RateLimitWindow` from `protocol/` which uses snake_case, but we want to expose camelCase everywhere, so we'll define a V2 version of that struct that serializes as camelCase). To set us up for a clean and isolated v2 API, generate types into a `v2/` namespace for both typescript and JSON schema. - TypeScript: v2 types emit under `out_dir/v2/*.ts`, and root index.ts now re-exports them via `export * as v2 from "./v2"`;. - JSON Schemas: v2 definitions bundle under `#/definitions/v2/*` rather than the root. The location for the original types (v1 and types pulled from `protocol/` and other core crates) haven't changed and are still at the root. This is for backwards compatibility: no breaking changes to existing usages of v1 APIs and types. **Notifications** While working on export.rs, I: - refactored server/client notifications with macros (like we already do for methods) so they also get exported (I noticed they weren't being exported at all). - removed the hardcoded list of types to export as JSON schema by leveraging the existing macros instead - and took a stab at API V2 notifications. These aren't wired up yet, and I expect to iterate on these this week. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-protocol/Cargo.toml | 1 + codex-rs/app-server-protocol/src/export.rs | 402 +++++++++++++----- .../src/protocol/common.rs | 172 +++++--- .../app-server-protocol/src/protocol/v2.rs | 300 ++++++++++++- .../app-server/src/codex_message_processor.rs | 9 +- codex-rs/app-server/src/outgoing_message.rs | 38 +- .../app-server/tests/suite/rate_limits.rs | 12 +- 8 files changed, 743 insertions(+), 192 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4961486e65..bf978581cc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "anyhow", "clap", "codex-protocol", + "mcp-types", "paste", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 58b0b9d607..5aa1c765e7 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -14,6 +14,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-protocol = { workspace = true } +mcp-types = { workspace = true } paste = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 3affa7afca..c5038a00d3 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2,20 +2,27 @@ use crate::ClientNotification; use crate::ClientRequest; use crate::ServerNotification; use crate::ServerRequest; +use crate::export_client_notification_schemas; +use crate::export_client_param_schemas; use crate::export_client_response_schemas; use crate::export_client_responses; +use crate::export_server_notification_schemas; +use crate::export_server_param_schemas; use crate::export_server_response_schemas; use crate::export_server_responses; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::SandboxPolicy; use schemars::JsonSchema; -use schemars::schema::RootSchema; use schemars::schema_for; use serde::Serialize; use serde_json::Map; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; use std::fs; @@ -28,84 +35,29 @@ use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; -macro_rules! for_each_schema_type { - ($macro:ident) => { - $macro!(crate::RequestId); - $macro!(crate::JSONRPCMessage); - $macro!(crate::JSONRPCRequest); - $macro!(crate::JSONRPCNotification); - $macro!(crate::JSONRPCResponse); - $macro!(crate::JSONRPCError); - $macro!(crate::JSONRPCErrorError); - $macro!(crate::AddConversationListenerParams); - $macro!(crate::AddConversationSubscriptionResponse); - $macro!(crate::ApplyPatchApprovalParams); - $macro!(crate::ApplyPatchApprovalResponse); - $macro!(crate::ArchiveConversationParams); - $macro!(crate::ArchiveConversationResponse); - $macro!(crate::AuthMode); - $macro!(crate::AccountUpdatedNotification); - $macro!(crate::AuthStatusChangeNotification); - $macro!(crate::CancelLoginChatGptParams); - $macro!(crate::CancelLoginChatGptResponse); - $macro!(crate::ClientInfo); - $macro!(crate::ClientNotification); - $macro!(crate::ClientRequest); - $macro!(crate::ConversationSummary); - $macro!(crate::ExecCommandApprovalParams); - $macro!(crate::ExecCommandApprovalResponse); - $macro!(crate::ExecOneOffCommandParams); - $macro!(crate::ExecOneOffCommandResponse); - $macro!(crate::FuzzyFileSearchParams); - $macro!(crate::FuzzyFileSearchResponse); - $macro!(crate::FuzzyFileSearchResult); - $macro!(crate::GetAuthStatusParams); - $macro!(crate::GetAuthStatusResponse); - $macro!(crate::GetUserAgentResponse); - $macro!(crate::GetUserSavedConfigResponse); - $macro!(crate::GitDiffToRemoteParams); - $macro!(crate::GitDiffToRemoteResponse); - $macro!(crate::GitSha); - $macro!(crate::InitializeParams); - $macro!(crate::InitializeResponse); - $macro!(crate::InputItem); - $macro!(crate::InterruptConversationParams); - $macro!(crate::InterruptConversationResponse); - $macro!(crate::ListConversationsParams); - $macro!(crate::ListConversationsResponse); - $macro!(crate::LoginApiKeyParams); - $macro!(crate::LoginApiKeyResponse); - $macro!(crate::LoginChatGptCompleteNotification); - $macro!(crate::LoginChatGptResponse); - $macro!(crate::LogoutChatGptParams); - $macro!(crate::LogoutChatGptResponse); - $macro!(crate::NewConversationParams); - $macro!(crate::NewConversationResponse); - $macro!(crate::Profile); - $macro!(crate::RemoveConversationListenerParams); - $macro!(crate::RemoveConversationSubscriptionResponse); - $macro!(crate::ResumeConversationParams); - $macro!(crate::ResumeConversationResponse); - $macro!(crate::SandboxSettings); - $macro!(crate::SendUserMessageParams); - $macro!(crate::SendUserMessageResponse); - $macro!(crate::SendUserTurnParams); - $macro!(crate::SendUserTurnResponse); - $macro!(crate::ServerNotification); - $macro!(crate::ServerRequest); - $macro!(crate::SessionConfiguredNotification); - $macro!(crate::SetDefaultModelParams); - $macro!(crate::SetDefaultModelResponse); - $macro!(crate::Tools); - $macro!(crate::UserInfoResponse); - $macro!(crate::UserSavedConfig); - $macro!(codex_protocol::protocol::EventMsg); - $macro!(codex_protocol::protocol::FileChange); - $macro!(codex_protocol::parse_command::ParsedCommand); - $macro!(codex_protocol::protocol::SandboxPolicy); - }; +#[derive(Clone)] +pub struct GeneratedSchema { + namespace: Option, + logical_name: String, + value: Value, + in_v1_dir: bool, } +impl GeneratedSchema { + fn namespace(&self) -> Option<&str> { + self.namespace.as_deref() + } + + fn logical_name(&self) -> &str { + &self.logical_name + } + + fn value(&self) -> &Value { + &self.value + } +} + +type JsonSchemaEmitter = fn(&Path) -> Result; pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { generate_ts(out_dir, prettier)?; generate_json(out_dir)?; @@ -113,7 +65,9 @@ pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { } pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + let v2_out_dir = out_dir.join("v2"); ensure_dir(out_dir)?; + ensure_dir(&v2_out_dir)?; ClientRequest::export_all_to(out_dir)?; export_client_responses(out_dir)?; @@ -124,12 +78,15 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { ServerNotification::export_all_to(out_dir)?; generate_index_ts(out_dir)?; + generate_index_ts(&v2_out_dir)?; - let ts_files = ts_files_in(out_dir)?; + // Ensure our header is present on all TS files (root + subdirs like v2/). + let ts_files = ts_files_in_recursive(out_dir)?; for file in &ts_files { prepend_header_if_missing(file)?; } + // Optionally run Prettier on all generated TS files. if let Some(prettier_bin) = prettier && !ts_files.is_empty() { @@ -148,23 +105,47 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { pub fn generate_json(out_dir: &Path) -> Result<()> { ensure_dir(out_dir)?; - let mut bundle: BTreeMap = BTreeMap::new(); + let envelope_emitters: &[JsonSchemaEmitter] = &[ + |d| write_json_schema_with_return::(d, "RequestId"), + |d| write_json_schema_with_return::(d, "JSONRPCMessage"), + |d| write_json_schema_with_return::(d, "JSONRPCRequest"), + |d| write_json_schema_with_return::(d, "JSONRPCNotification"), + |d| write_json_schema_with_return::(d, "JSONRPCResponse"), + |d| write_json_schema_with_return::(d, "JSONRPCError"), + |d| write_json_schema_with_return::(d, "JSONRPCErrorError"), + |d| write_json_schema_with_return::(d, "ClientRequest"), + |d| write_json_schema_with_return::(d, "ServerRequest"), + |d| write_json_schema_with_return::(d, "ClientNotification"), + |d| write_json_schema_with_return::(d, "ServerNotification"), + |d| write_json_schema_with_return::(d, "EventMsg"), + |d| write_json_schema_with_return::(d, "FileChange"), + |d| write_json_schema_with_return::(d, "InputItem"), + |d| write_json_schema_with_return::(d, "ParsedCommand"), + |d| write_json_schema_with_return::(d, "SandboxPolicy"), + ]; - macro_rules! add_schema { - ($ty:path) => {{ - let name = type_basename(stringify!($ty)); - let schema = write_json_schema_with_return::<$ty>(out_dir, &name)?; - bundle.insert(name, schema); - }}; + let mut schemas: Vec = Vec::new(); + for emit in envelope_emitters { + schemas.push(emit(out_dir)?); } - for_each_schema_type!(add_schema); + schemas.extend(export_client_param_schemas(out_dir)?); + schemas.extend(export_client_response_schemas(out_dir)?); + schemas.extend(export_server_param_schemas(out_dir)?); + schemas.extend(export_server_response_schemas(out_dir)?); + schemas.extend(export_client_notification_schemas(out_dir)?); + schemas.extend(export_server_notification_schemas(out_dir)?); - export_client_response_schemas(out_dir)?; - export_server_response_schemas(out_dir)?; + let bundle = build_schema_bundle(schemas)?; + write_pretty_json( + out_dir.join("codex_app_server_protocol.schemas.json"), + &bundle, + )?; - let mut definitions = Map::new(); + Ok(()) +} +fn build_schema_bundle(schemas: Vec) -> Result { const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", "ClientRequest", @@ -177,22 +158,62 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { "ServerRequest", ]; - for (name, schema) in bundle { - let mut schema_value = serde_json::to_value(schema)?; - annotate_schema(&mut schema_value, Some(name.as_str())); + let namespaced_types = collect_namespaced_types(&schemas); + let mut definitions = Map::new(); - if let Value::Object(ref mut obj) = schema_value + for schema in schemas { + let GeneratedSchema { + namespace, + logical_name, + mut value, + in_v1_dir, + } = schema; + + if let Some(ref ns) = namespace { + rewrite_refs_to_namespace(&mut value, ns); + } + + let mut forced_namespace_refs: Vec<(String, String)> = Vec::new(); + if let Value::Object(ref mut obj) = value && let Some(defs) = obj.remove("definitions") && let Value::Object(defs_obj) = defs { for (def_name, mut def_schema) in defs_obj { - if !SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { - annotate_schema(&mut def_schema, Some(def_name.as_str())); + if SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + continue; + } + annotate_schema(&mut def_schema, Some(def_name.as_str())); + let target_namespace = match namespace { + Some(ref ns) => Some(ns.clone()), + None => namespace_for_definition(&def_name, &namespaced_types) + .cloned() + .filter(|_| !in_v1_dir), + }; + if let Some(ref ns) = target_namespace { + if namespace.as_deref() == Some(ns.as_str()) { + rewrite_refs_to_namespace(&mut def_schema, ns); + insert_into_namespace(&mut definitions, ns, def_name.clone(), def_schema)?; + } else if !forced_namespace_refs + .iter() + .any(|(name, existing_ns)| name == &def_name && existing_ns == ns) + { + forced_namespace_refs.push((def_name.clone(), ns.clone())); + } + } else { definitions.insert(def_name, def_schema); } } } - definitions.insert(name, schema_value); + + for (name, ns) in forced_namespace_refs { + rewrite_named_ref_to_namespace(&mut value, &ns, &name); + } + + if let Some(ref ns) = namespace { + insert_into_namespace(&mut definitions, ns, logical_name.clone(), value)?; + } else { + definitions.insert(logical_name, value); + } } let mut root = Map::new(); @@ -207,15 +228,28 @@ pub fn generate_json(out_dir: &Path) -> Result<()> { root.insert("type".to_string(), Value::String("object".into())); root.insert("definitions".to_string(), Value::Object(definitions)); - write_pretty_json( - out_dir.join("codex_app_server_protocol.schemas.json"), - &Value::Object(root), - )?; - - Ok(()) + Ok(Value::Object(root)) } -fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result +fn insert_into_namespace( + definitions: &mut Map, + namespace: &str, + name: String, + schema: Value, +) -> Result<()> { + let entry = definitions + .entry(namespace.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + match entry { + Value::Object(map) => { + map.insert(name, schema); + Ok(()) + } + _ => Err(anyhow!("expected namespace {namespace} to be an object")), + } +} + +fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result where T: JsonSchema, { @@ -223,17 +257,37 @@ where let schema = schema_for!(T); let mut schema_value = serde_json::to_value(schema)?; annotate_schema(&mut schema_value, Some(file_stem)); - write_pretty_json(out_dir.join(format!("{file_stem}.json")), &schema_value) + // 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 + // write alongside the legacy files. + let (raw_namespace, logical_name) = split_namespace(file_stem); + let out_path = if let Some(ns) = raw_namespace { + let dir = out_dir.join(ns); + ensure_dir(&dir)?; + dir.join(format!("{logical_name}.json")) + } else { + out_dir.join(format!("{file_stem}.json")) + }; + + write_pretty_json(out_path, &schema_value) .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; - let annotated_schema = serde_json::from_value(schema_value)?; - Ok(annotated_schema) + let namespace = match raw_namespace { + Some("v1") | None => None, + Some(ns) => Some(ns.to_string()), + }; + Ok(GeneratedSchema { + in_v1_dir: raw_namespace == Some("v1"), + namespace, + logical_name: logical_name.to_string(), + value: schema_value, + }) } -pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result<()> +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result where T: JsonSchema, { - write_json_schema_with_return::(out_dir, name).map(|_| ()) + write_json_schema_with_return::(out_dir, name) } fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { @@ -242,13 +296,73 @@ fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) } -fn type_basename(type_path: &str) -> String { - type_path - .rsplit_once("::") - .map(|(_, name)| name) - .unwrap_or(type_path) - .trim() - .to_string() + +/// Split a fully-qualified type name like "v2::Type" into its namespace and logical name. +fn split_namespace(name: &str) -> (Option<&str>, &str) { + name.split_once("::") + .map_or((None, name), |(ns, rest)| (Some(ns), rest)) +} + +/// Recursively rewrite $ref values that point at "#/definitions/..." so that +/// they point to a namespaced location under the bundle. +fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(r)) = obj.get_mut("$ref") + && let Some(suffix) = r.strip_prefix("#/definitions/") + { + let prefix = format!("{ns}/"); + if !suffix.starts_with(&prefix) { + *r = format!("#/definitions/{ns}/{suffix}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + _ => {} + } +} + +fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { + let mut types = HashMap::new(); + for schema in schemas { + if let Some(ns) = schema.namespace() { + types + .entry(schema.logical_name().to_string()) + .or_insert_with(|| ns.to_string()); + if let Some(Value::Object(defs)) = schema.value().get("definitions") { + for key in defs.keys() { + types.entry(key.clone()).or_insert_with(|| ns.to_string()); + } + } + if let Some(Value::Object(defs)) = schema.value().get("$defs") { + for key in defs.keys() { + types.entry(key.clone()).or_insert_with(|| ns.to_string()); + } + } + } + } + types +} + +fn namespace_for_definition<'a>( + name: &str, + types: &'a HashMap, +) -> Option<&'a String> { + if let Some(ns) = types.get(name) { + return Some(ns); + } + let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit()); + if trimmed != name { + return types.get(trimmed); + } + None } fn variant_definition_name(base: &str, variant: &Value) -> Option { @@ -468,6 +582,33 @@ fn ensure_dir(dir: &Path) -> Result<()> { .with_context(|| format!("Failed to create output directory {}", dir.display())) } +fn rewrite_named_ref_to_namespace(value: &mut Value, ns: &str, name: &str) { + let direct = format!("#/definitions/{name}"); + let prefixed = format!("{direct}/"); + let replacement = format!("#/definitions/{ns}/{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_ref_to_namespace(child, ns, name); + } + } + Value::Array(items) => { + for child in items { + rewrite_named_ref_to_namespace(child, ns, name); + } + } + _ => {} + } +} + fn prepend_header_if_missing(path: &Path) -> Result<()> { let mut content = String::new(); { @@ -505,6 +646,26 @@ fn ts_files_in(dir: &Path) -> Result> { Ok(files) } +fn ts_files_in_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in + fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + fn generate_index_ts(out_dir: &Path) -> Result { let mut entries: Vec = Vec::new(); let mut stems: Vec = ts_files_in(out_dir)? @@ -521,6 +682,14 @@ fn generate_index_ts(out_dir: &Path) -> Result { entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); } + // If this is the root out_dir and a ./v2 folder exists with TS files, + // expose it as a namespace to avoid symbol collisions at the root. + let v2_dir = out_dir.join("v2"); + let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); + if has_v2_ts { + entries.push("export * as v2 from \"./v2\";\n".to_string()); + } + let mut content = String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); content.push_str(HEADER); @@ -547,6 +716,7 @@ mod tests { #[test] fn generated_ts_has_no_optional_nullable_fields() -> Result<()> { + // Assert that there are no types of the form "?: T | null" in the generated TS files. let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index efc60830c2..75618905a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -4,12 +4,13 @@ use std::path::PathBuf; use crate::JSONRPCNotification; use crate::JSONRPCRequest; use crate::RequestId; +use crate::export::GeneratedSchema; +use crate::export::write_json_schema; use crate::protocol::v1; use crate::protocol::v2; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxCommandAssessment; use paste::paste; @@ -74,13 +75,26 @@ macro_rules! client_request_definitions { Ok(()) } + #[allow(clippy::vec_init_then_push)] pub fn export_client_response_schemas( out_dir: &::std::path::Path, - ) -> ::anyhow::Result<()> { + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); $( - crate::export::write_json_schema::<$response>(out_dir, stringify!($response))?; + schemas.push(write_json_schema::<$response>(out_dir, stringify!($response))?); )* - Ok(()) + Ok(schemas) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_client_param_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + $( + schemas.push(write_json_schema::<$params>(out_dir, stringify!($params))?); + )* + Ok(schemas) } }; } @@ -276,13 +290,101 @@ macro_rules! server_request_definitions { Ok(()) } + #[allow(clippy::vec_init_then_push)] pub fn export_server_response_schemas( out_dir: &::std::path::Path, - ) -> ::anyhow::Result<()> { + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); paste! { - $(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?;)* + $(schemas.push(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?);)* } - Ok(()) + Ok(schemas) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_server_param_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + paste! { + $(schemas.push(crate::export::write_json_schema::<[<$variant Params>]>(out_dir, stringify!([<$variant Params>]))?);)* + } + Ok(schemas) + } + }; +} + +/// Generates `ServerNotification` enum and helpers, including a JSON Schema +/// exporter for each notification. +macro_rules! server_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident $(=> $wire:literal)? ( $payload:ty ) + ),* $(,)? + ) => { + /// Notification sent from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ServerNotification { + $( + $(#[$variant_meta])* + $(#[serde(rename = $wire)] #[ts(rename = $wire)] #[strum(serialize = $wire)])? + $variant($payload), + )* + } + + impl ServerNotification { + pub fn to_params(self) -> Result { + match self { + $(Self::$variant(params) => serde_json::to_value(params),)* + } + } + } + + impl TryFrom for ServerNotification { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCNotification) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_server_notification_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + $(schemas.push(crate::export::write_json_schema::<$payload>(out_dir, stringify!($payload))?);)* + Ok(schemas) + } + }; +} +/// Notifications sent from the client to the server. +macro_rules! client_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident $( ( $payload:ty ) )? + ),* $(,)? + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ClientNotification { + $( + $(#[$variant_meta])* + $variant $( ( $payload ) )?, + )* + } + + pub fn export_client_notification_schemas( + _out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let schemas = Vec::new(); + $( $(schemas.push(crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?);)? )* + Ok(schemas) } }; } @@ -366,58 +468,26 @@ pub struct FuzzyFileSearchResponse { pub files: Vec, } -/// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ServerNotification { +server_notification_definitions! { /// NEW NOTIFICATIONS - #[serde(rename = "account/updated")] - #[ts(rename = "account/updated")] - #[strum(serialize = "account/updated")] - AccountUpdated(v2::AccountUpdatedNotification), - - #[serde(rename = "account/rateLimits/updated")] - #[ts(rename = "account/rateLimits/updated")] - #[strum(serialize = "account/rateLimits/updated")] - AccountRateLimitsUpdated(RateLimitSnapshot), + ThreadStarted => "thread/started" (v2::ThreadStartedNotification), + TurnStarted => "turn/started" (v2::TurnStartedNotification), + TurnCompleted => "turn/completed" (v2::TurnCompletedNotification), + ItemStarted => "item/started" (v2::ItemStartedNotification), + ItemCompleted => "item/completed" (v2::ItemCompletedNotification), + AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), + CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), + AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), /// DEPRECATED NOTIFICATIONS below - /// Authentication status changed AuthStatusChange(v1::AuthStatusChangeNotification), - - /// ChatGPT login flow completed LoginChatGptComplete(v1::LoginChatGptCompleteNotification), - - /// The special session configured event for a new or resumed conversation. SessionConfigured(v1::SessionConfiguredNotification), } -impl ServerNotification { - pub fn to_params(self) -> Result { - match self { - ServerNotification::AccountUpdated(params) => serde_json::to_value(params), - ServerNotification::AccountRateLimitsUpdated(params) => serde_json::to_value(params), - ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), - ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), - ServerNotification::SessionConfigured(params) => serde_json::to_value(params), - } - } -} - -impl TryFrom for ServerNotification { - type Error = serde_json::Error; - - fn try_from(value: JSONRPCNotification) -> Result { - serde_json::from_value(serde_json::to_value(value)?) - } -} - -/// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ClientNotification { +client_notification_definitions! { Initialized, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8c39628412..96cb2d768e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2,16 +2,21 @@ use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; -use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use mcp_types::ContentBlock as McpContentBlock; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; +use std::path::PathBuf; use ts_rs::TS; use uuid::Uuid; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum Account { #[serde(rename = "apiKey", rename_all = "camelCase")] #[ts(rename = "apiKey", rename_all = "camelCase")] @@ -28,6 +33,7 @@ pub enum Account { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type")] #[ts(tag = "type")] +#[ts(export_to = "v2/")] pub enum LoginAccountParams { #[serde(rename = "apiKey")] #[ts(rename = "apiKey")] @@ -43,6 +49,7 @@ pub enum LoginAccountParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LoginAccountResponse { /// Only set if the login method is ChatGPT. #[schemars(with = "String")] @@ -55,22 +62,26 @@ pub struct LoginAccountResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct LogoutAccountResponse {} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountRateLimitsResponse { pub rate_limits: RateLimitSnapshot, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct GetAccountResponse { pub account: Account, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ListModelsParams { /// Optional page size; defaults to a reasonable server-side value. pub page_size: Option, @@ -80,6 +91,7 @@ pub struct ListModelsParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct Model { pub id: String, pub model: String, @@ -93,6 +105,7 @@ pub struct Model { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ReasoningEffortOption { pub reasoning_effort: ReasoningEffort, pub description: String, @@ -100,6 +113,7 @@ pub struct ReasoningEffortOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct ListModelsResponse { pub items: Vec, /// Opaque cursor to pass to the next call to continue after the last item. @@ -109,6 +123,7 @@ pub struct ListModelsResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct UploadFeedbackParams { pub classification: String, pub reason: Option, @@ -118,12 +133,295 @@ pub struct UploadFeedbackParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct UploadFeedbackResponse { pub thread_id: String, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] pub struct AccountUpdatedNotification { pub auth_method: Option, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + pub items: Vec, + pub status: TurnStatus, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { text: String }, + Image { url: String }, + LocalImage { path: PathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + UserMessage { + id: String, + content: Vec, + }, + AgentMessage { + id: String, + text: String, + }, + Reasoning { + id: String, + text: String, + }, + CommandExecution { + id: String, + command: String, + aggregated_output: String, + exit_code: Option, + status: CommandExecutionStatus, + duration_ms: Option, + }, + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + result: Option, + error: Option, + }, + WebSearch { + id: String, + query: String, + }, + TodoList { + id: String, + items: Vec, + }, + ImageView { + id: String, + path: String, + }, + CodeReview { + id: String, + review: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + pub content: Vec, + pub structured_content: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TodoItem { + pub id: String, + pub text: String, + pub completed: bool, +} + +// === Server Notifications === + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub turn: Turn, + pub usage: Usage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub primary: Option, + pub secondary: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + pub window_duration_mins: Option, + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8ea4cef8ba..3609b04403 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; @@ -625,7 +626,9 @@ impl CodexMessageProcessor { async fn get_account_rate_limits(&self, request_id: RequestId) { match self.fetch_account_rate_limits().await { Ok(rate_limits) => { - let response = GetAccountRateLimitsResponse { rate_limits }; + let response = GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + }; self.outgoing.send_response(request_id, response).await; } Err(error) => { @@ -1766,7 +1769,9 @@ async fn apply_bespoke_event_handling( if let Some(rate_limits) = token_count_event.rate_limits { outgoing .send_server_notification(ServerNotification::AccountRateLimitsUpdated( - rate_limits, + AccountRateLimitsUpdatedNotification { + rate_limits: rate_limits.into(), + }, )) .await; } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3d56c9bd83..d4dca94e8a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -141,11 +141,12 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { + use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::LoginChatGptCompleteNotification; - use codex_protocol::protocol::RateLimitSnapshot; - use codex_protocol::protocol::RateLimitWindow; + use codex_app_server_protocol::RateLimitSnapshot; + use codex_app_server_protocol::RateLimitWindow; use pretty_assertions::assert_eq; use serde_json::json; use uuid::Uuid; @@ -179,26 +180,31 @@ mod tests { #[test] fn verify_account_rate_limits_notification_serialization() { - let notification = ServerNotification::AccountRateLimitsUpdated(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 25.0, - window_minutes: Some(15), - resets_at: Some(123), - }), - secondary: None, - }); + let notification = + ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { + rate_limits: RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25, + window_duration_mins: Some(15), + resets_at: Some(123), + }), + secondary: None, + }, + }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); assert_eq!( json!({ "method": "account/rateLimits/updated", "params": { - "primary": { - "used_percent": 25.0, - "window_minutes": 15, - "resets_at": 123, - }, - "secondary": null, + "rateLimits": { + "primary": { + "usedPercent": 25, + "windowDurationMins": 15, + "resetsAt": 123 + }, + "secondary": null + } }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/suite/rate_limits.rs b/codex-rs/app-server/tests/suite/rate_limits.rs index 16cbf153e9..c6ef7dccea 100644 --- a/codex-rs/app-server/tests/suite/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/rate_limits.rs @@ -7,10 +7,10 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; -use codex_protocol::protocol::RateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; @@ -143,13 +143,13 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { let expected = GetAccountRateLimitsResponse { rate_limits: RateLimitSnapshot { primary: Some(RateLimitWindow { - used_percent: 42.0, - window_minutes: Some(60), + used_percent: 42, + window_duration_mins: Some(60), resets_at: Some(primary_reset_timestamp), }), secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(1440), + used_percent: 5, + window_duration_mins: Some(1440), resets_at: Some(secondary_reset_timestamp), }), }, From 1575f0504ca117f79a5d0c959b00cc18ae14b762 Mon Sep 17 00:00:00 2001 From: Lukas <56000765+lukasl-dev@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:07:37 +0100 Subject: [PATCH 028/122] Fix nix build (#6230) Previously, the `nix build .#default` command fails due to a missing output hash in the `./codex-rs/default.nix` for `crossterm-0.28.1`: ``` error: No hash was found while vendoring the git dependency crossterm-0.28.1. You can add a hash through the `outputHashes` argument of `importCargoLock`: outputHashes = { "crossterm-0.28.1" = ""; }; If you use `buildRustPackage`, you can add this attribute to the `cargoLock` attribute set. ``` This PR adds the missing hash: ```diff cargoLock.outputHashes = { "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + "crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728="; }; ``` With this change, `nix build .#default` succeeds: ``` > nix build .#default --max-jobs 1 --cores 2 warning: Git tree '/home/lukas/r/github.com/lukasl-dev/codex' is dirty [1/0/1 built] building codex-rs-0.1.0 (buildPhase)[1/0/1 built] building codex-rs-0.1.0 (buildP[1/0/1 built] building codex-rs-0.1.0 (buildPhase): [1/0/1 built] building codex-rs-0.1.0 (b[1/0/1 built] building codex-rs-0.1.0 (buildPhase): Compi[1/0/1 built] building codex-rs-0.1 > ./result/bin/codex You are running Codex in /home/lukas/r/github.com/lukasl-dev/codex Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval. ... ``` --- codex-rs/default.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index 3d598a9c2b..26971f1846 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -5,7 +5,7 @@ lib, ... }: -rustPlatform.buildRustPackage (finalAttrs: { +rustPlatform.buildRustPackage (_: { env = { PKG_CONFIG_PATH = "${openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; }; @@ -21,6 +21,7 @@ rustPlatform.buildRustPackage (finalAttrs: { cargoLock.outputHashes = { "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + "crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728="; }; meta = with lib; { From fff576cf98464e05ecf49ae4907dea6119ef8c31 Mon Sep 17 00:00:00 2001 From: Soroush Yousefpour <46920873+gabrimatic@users.noreply.github.com> Date: Wed, 5 Nov 2025 02:44:02 +0100 Subject: [PATCH 029/122] fix(core): load custom prompts from symlinked Markdown files (#3643) - Discover prompts via fs::metadata to follow symlinks - Add Unix-only symlink test in custom_prompts.rs - Update docs/prompts.md to mention symlinks Fixes #3637 --------- Signed-off-by: Soroush Yousefpour Co-authored-by: dedrisian-oai Co-authored-by: Eric Traut --- codex-rs/core/src/custom_prompts.rs | 26 ++++++++++++++++++++++---- docs/prompts.md | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index d92c683902..66b2bab32c 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -32,12 +32,11 @@ pub async fn discover_prompts_in_excluding( while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - let is_file = entry - .file_type() + let is_file_like = fs::metadata(&path) .await - .map(|ft| ft.is_file()) + .map(|m| m.is_file()) .unwrap_or(false); - if !is_file { + if !is_file_like { continue; } // Only include Markdown files with a .md extension. @@ -197,6 +196,25 @@ mod tests { assert_eq!(names, vec!["good"]); } + #[tokio::test] + #[cfg(unix)] + async fn discovers_symlinked_md_files() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + + // Create a real file + fs::write(dir.join("real.md"), b"real content").unwrap(); + + // Create a symlink to the real file + std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap(); + + let found = discover_prompts_in(dir).await; + let names: Vec = found.into_iter().map(|e| e.name).collect(); + + // Both real and link should be discovered, sorted alphabetically + assert_eq!(names, vec!["link", "real"]); + } + #[tokio::test] async fn parses_frontmatter_and_strips_from_body() { let tmp = tempdir().expect("create TempDir"); diff --git a/docs/prompts.md b/docs/prompts.md index 0c2eb3bcd6..7b47d9383a 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -5,7 +5,7 @@ Custom prompts turn your repeatable instructions into reusable slash commands, s ### Where prompts live - Location: store prompts in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). Set `CODEX_HOME` if you want to use a different folder. -- File type: Codex only loads `.md` files. Non-Markdown files are ignored. +- File type: Codex only loads `.md` files. Non-Markdown files are ignored. Both regular files and symlinks to Markdown files are supported. - Naming: The filename (without `.md`) becomes the prompt name. A file called `review.md` registers the prompt `review`. - Refresh: Prompts are loaded when a session starts. Restart Codex (or start a new session) after adding or editing files. - Conflicts: Files whose names collide with built-in commands (like `init`) stay hidden in the slash popup, but you can still invoke them with `/prompts:`. From 95af4179233e2b6f446c5d6ad20bdc5a2f876c98 Mon Sep 17 00:00:00 2001 From: Andrew Dirksen Date: Tue, 4 Nov 2025 17:54:46 -0800 Subject: [PATCH 030/122] allow codex to be run from pid 1 (#4200) Previously it was not possible for codex to run commands as the init process (pid 1) in linux. Commands run in containers tend to see their own pid as 1. See https://github.com/openai/codex/issues/4198 This pr implements the solution mentioned in that issue. Co-authored-by: Eric Traut --- codex-rs/core/src/spawn.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index 1c82df3180..c3a912664b 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -67,7 +67,8 @@ pub(crate) async fn spawn_child_async( // This relies on prctl(2), so it only works on Linux. #[cfg(target_os = "linux")] unsafe { - cmd.pre_exec(|| { + let parent_pid = libc::getpid(); + cmd.pre_exec(move || { // This prctl call effectively requests, "deliver SIGTERM when my // current parent dies." if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { @@ -76,9 +77,10 @@ pub(crate) async fn spawn_child_async( // Though if there was a race condition and this pre_exec() block is // run _after_ the parent (i.e., the Codex process) has already - // exited, then the parent is the _init_ process (which will never - // die), so we should just terminate the child process now. - if libc::getppid() == 1 { + // exited, then parent will be the closest configured "subreaper" + // ancestor process, or PID 1 (init). If the Codex process has exited + // already, so should the child process. + if libc::getppid() != parent_pid { libc::raise(libc::SIGTERM); } Ok(()) From 9b538a8672eb7fe36c6156c6fb6f2555852700ce Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 4 Nov 2025 21:23:24 -0800 Subject: [PATCH 031/122] Upgrade rmcp to 0.8.4 (#6234) Picks up https://github.com/modelcontextprotocol/rust-sdk/pull/509 which fixes https://github.com/openai/codex/issues/6164 --- codex-rs/Cargo.lock | 8 ++-- codex-rs/Cargo.toml | 9 +++- .../src/bin/test_streamable_http_server.rs | 42 +++++++++++++++---- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bf978581cc..b584c85f20 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5010,9 +5010,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdad1258f7259fdc0f2dfc266939c82c3b5d1fd72bcde274d600cdc27e60243" +checksum = "a2c66318b30535ccd0d3b39afaa4240d6b5a35328fb7672a28e3386c97472805" dependencies = [ "base64", "bytes", @@ -5044,9 +5044,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede0589a208cc7ce81d1be68aa7e74b917fcd03c81528408bab0457e187dcd9b" +checksum = "49a19193e0d69bb1c96324b1b4daec078d4c8e76d734e53404c107437858a4d2" dependencies = [ "darling 0.21.3", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 73c401b034..3de8d47d59 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -162,7 +162,7 @@ ratatui = "0.29.0" ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" -rmcp = { version = "0.8.3", default-features = false } +rmcp = { version = "0.8.4", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" sentry = "0.34.0" @@ -256,7 +256,12 @@ unwrap_used = "deny" # cargo-shear cannot see the platform-specific openssl-sys usage, so we # silence the false positive here instead of deleting a real dependency. [workspace.metadata.cargo-shear] -ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness", "codex-utils-tokenizer"] +ignored = [ + "icu_provider", + "openssl-sys", + "codex-utils-readiness", + "codex-utils-tokenizer", +] [profile.release] lto = "fat" diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 30d8fd7fdf..f56a858241 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -10,9 +10,11 @@ use axum::extract::State; use axum::http::Request; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; +use axum::http::header::CONTENT_TYPE; use axum::middleware; use axum::middleware::Next; use axum::response::Response; +use axum::routing::get; use rmcp::ErrorData as McpError; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; @@ -256,14 +258,35 @@ async fn main() -> Result<(), Box> { }; eprintln!("starting rmcp streamable http test server on http://{bind_addr}/mcp"); - let router = Router::new().nest_service( - "/mcp", - StreamableHttpService::new( - || Ok(TestToolServer::new()), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default(), - ), - ); + let router = Router::new() + .route( + "/.well-known/oauth-authorization-server/mcp", + get({ + move || async move { + let metadata_base = format!("http://{bind_addr}"); + #[expect(clippy::expect_used)] + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "authorization_endpoint": format!("{metadata_base}/oauth/authorize"), + "token_endpoint": format!("{metadata_base}/oauth/token"), + "scopes_supported": [""], + })).expect("failed to serialize metadata"), + )) + .expect("valid metadata response") + } + }), + ) + .nest_service( + "/mcp", + StreamableHttpService::new( + || Ok(TestToolServer::new()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ), + ); let router = if let Ok(token) = std::env::var("MCP_EXPECT_BEARER") { let expected = Arc::new(format!("Bearer {token}")); @@ -282,6 +305,9 @@ async fn require_bearer( request: Request, next: Next, ) -> Result { + if request.uri().path().contains("/.well-known/") { + return Ok(next.run(request).await); + } if request .headers() .get(AUTHORIZATION) From 9a10e80ab75f248d8869fd5dc8e9d20ce97d5df7 Mon Sep 17 00:00:00 2001 From: Dan Hernandez <33551757+danfhernandez@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:51:03 -0800 Subject: [PATCH 032/122] Add modelReasoningEffort option to TypeScript SDK (#6237) ## Summary - Adds `ModelReasoningEffort` type to TypeScript SDK with values: `minimal`, `low`, `medium`, `high` - Adds `modelReasoningEffort` option to `ThreadOptions` - Forwards the option to the codex CLI via `--config model_reasoning_effort=""` - Includes test coverage for the new option ## Changes - `sdk/typescript/src/threadOptions.ts`: Define `ModelReasoningEffort` type and add to `ThreadOptions` - `sdk/typescript/src/index.ts`: Export `ModelReasoningEffort` type - `sdk/typescript/src/exec.ts`: Forward `modelReasoningEffort` to CLI as config flag - `sdk/typescript/src/thread.ts`: Pass option through to exec (+ debug logging) - `sdk/typescript/tests/run.test.ts`: Add test for `modelReasoningEffort` flag forwarding --------- Co-authored-by: Eric Traut --- sdk/typescript/src/exec.ts | 8 +++++++- sdk/typescript/src/index.ts | 7 ++++++- sdk/typescript/src/thread.ts | 1 + sdk/typescript/src/threadOptions.ts | 3 +++ sdk/typescript/tests/run.test.ts | 31 +++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 45ae5fc646..5c7b1846e3 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -3,7 +3,7 @@ import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { SandboxMode } from "./threadOptions"; +import { SandboxMode, ModelReasoningEffort } from "./threadOptions"; export type CodexExecArgs = { input: string; @@ -22,6 +22,8 @@ export type CodexExecArgs = { skipGitRepoCheck?: boolean; // --output-schema outputSchemaFile?: string; + // --config model_reasoning_effort + modelReasoningEffort?: ModelReasoningEffort; }; const INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; @@ -56,6 +58,10 @@ export class CodexExec { commandArgs.push("--output-schema", args.outputSchemaFile); } + if (args.modelReasoningEffort) { + commandArgs.push("--config", `model_reasoning_effort="${args.modelReasoningEffort}"`); + } + if (args.images?.length) { for (const image of args.images) { commandArgs.push("--image", image); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 8e6a71df8c..cfd0dc4318 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -30,5 +30,10 @@ export { Codex } from "./codex"; export type { CodexOptions } from "./codexOptions"; -export type { ThreadOptions, ApprovalMode, SandboxMode } from "./threadOptions"; +export type { + ThreadOptions, + ApprovalMode, + SandboxMode, + ModelReasoningEffort, +} from "./threadOptions"; export type { TurnOptions } from "./turnOptions"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 2306df25b6..0848852167 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -85,6 +85,7 @@ export class Thread { workingDirectory: options?.workingDirectory, skipGitRepoCheck: options?.skipGitRepoCheck, outputSchemaFile: schemaPath, + modelReasoningEffort: options?.modelReasoningEffort, }); try { for await (const item of generator) { diff --git a/sdk/typescript/src/threadOptions.ts b/sdk/typescript/src/threadOptions.ts index 7f01488d23..53895c9ab3 100644 --- a/sdk/typescript/src/threadOptions.ts +++ b/sdk/typescript/src/threadOptions.ts @@ -2,9 +2,12 @@ export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted"; export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; +export type ModelReasoningEffort = "minimal" | "low" | "medium" | "high"; + export type ThreadOptions = { model?: string; sandboxMode?: SandboxMode; workingDirectory?: string; skipGitRepoCheck?: boolean; + modelReasoningEffort?: ModelReasoningEffort; }; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index f495b1e6ec..f6eefa0c5d 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -223,6 +223,37 @@ describe("Codex", () => { } }); + it("passes modelReasoningEffort to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Reasoning effort applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + modelReasoningEffort: "high", + }); + await thread.run("apply reasoning effort"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", 'model_reasoning_effort="high"']); + } finally { + restore(); + await close(); + } + }); + it("writes output schema to a temporary file and forwards it", async () => { const { url, close, requests } = await startResponsesTestProxy({ statusCode: 200, From 62474a30e8b4a69a8639ae0889bcf7f576dc82f7 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:50:40 -0800 Subject: [PATCH 033/122] tui: refactor ChatWidget and BottomPane to use Renderables (#5565) - introduce RenderableItem to support both owned and borrowed children in composite Renderables - refactor some of our gnarlier manual layouts, BottomPane and ChatWidget, to use ColumnRenderable - Renderable and friends now handle cursor_pos() --- codex-rs/tui/src/app.rs | 3 +- .../tui/src/bottom_pane/approval_overlay.rs | 8 +- .../tui/src/bottom_pane/bottom_pane_view.rs | 6 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 97 +++---- .../tui/src/bottom_pane/custom_prompt_view.rs | 40 +-- codex-rs/tui/src/bottom_pane/feedback_view.rs | 12 +- codex-rs/tui/src/bottom_pane/mod.rs | 200 +++---------- ..._chat_composer__tests__slash_popup_mo.snap | 1 + ...s_visible_when_status_hidden_snapshot.snap | 1 - ...er_fill_height_without_bottom_padding.snap | 1 - ...__status_and_queued_messages_snapshot.snap | 1 - ...hidden_when_height_too_small_height_2.snap | 6 - codex-rs/tui/src/chatwidget.rs | 116 ++++---- ...chatwidget__tests__chat_small_idle_h1.snap | 2 +- ...chatwidget__tests__chat_small_idle_h2.snap | 2 +- ...chatwidget__tests__chat_small_idle_h3.snap | 3 +- ...twidget__tests__chat_small_running_h1.snap | 2 +- ...twidget__tests__chat_small_running_h2.snap | 3 +- ...twidget__tests__chat_small_running_h3.snap | 5 +- ...i__chatwidget__tests__chatwidget_tall.snap | 27 ++ codex-rs/tui/src/chatwidget/tests.rs | 57 ++-- codex-rs/tui/src/diff_render.rs | 2 +- codex-rs/tui/src/exec_cell/render.rs | 28 -- codex-rs/tui/src/history_cell.rs | 37 +-- .../tui/src/public_widgets/composer_input.rs | 4 +- codex-rs/tui/src/render/renderable.rs | 263 ++++++++++++++++-- codex-rs/tui/src/status_indicator_widget.rs | 25 +- 27 files changed, 512 insertions(+), 440 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3c06113cb0..1d2c9690d5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -9,6 +9,7 @@ use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; +use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; @@ -233,7 +234,7 @@ impl App { tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { - frame.render_widget_ref(&self.chat_widget, frame.area()); + self.chat_widget.render(frame.area(), frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { frame.set_cursor_position((x, y)); } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 140b5386c4..d1d434c052 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -260,10 +260,6 @@ impl BottomPaneView for ApprovalOverlay { self.enqueue_request(request); None } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.list.cursor_pos(area) - } } impl Renderable for ApprovalOverlay { @@ -274,6 +270,10 @@ impl Renderable for ApprovalOverlay { fn render(&self, area: Rect, buf: &mut Buffer) { self.list.render(area, buf); } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.list.cursor_pos(area) + } } struct ApprovalRequestState { diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 0271a7e19b..499801cbb0 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,7 +1,6 @@ use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; use crossterm::event::KeyEvent; -use ratatui::layout::Rect; use super::CancellationEvent; @@ -27,11 +26,6 @@ pub(crate) trait BottomPaneView: Renderable { false } - /// Cursor position when this view is active. - fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { - None - } - /// Try to handle approval request; return the original value if not /// consumed. fn try_consume_approval_request( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8691b5b127..61539b6ed3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -35,6 +35,9 @@ use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +use crate::render::Insets; +use crate::render::RectExt; +use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; @@ -158,24 +161,6 @@ impl ChatComposer { this } - pub fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { - ActivePopup::None => footer_total_height, - ActivePopup::Command(c) => c.calculate_required_height(width), - ActivePopup::File(c) => c.calculate_required_height(), - } - } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -190,18 +175,9 @@ impl ChatComposer { ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), ActivePopup::None => Constraint::Max(footer_total_height), }; - let mut area = area; - if area.height > 1 { - area.height -= 1; - area.y += 1; - } let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); - let mut textarea_rect = composer_rect; - textarea_rect.width = textarea_rect.width.saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ); - textarea_rect.x = textarea_rect.x.saturating_add(LIVE_PREFIX_COLS); + Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); + let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); [composer_rect, textarea_rect, popup_rect] } @@ -213,12 +189,6 @@ impl ChatComposer { } } - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -1541,8 +1511,32 @@ impl ChatComposer { } } -impl WidgetRef for ChatComposer { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { +impl Renderable for ChatComposer { + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let [_, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } + + fn desired_height(&self, width: u16) -> u16 { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let footer_total_height = footer_hint_height + footer_spacing; + const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; + self.textarea + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + + 2 + + match &self.active_popup { + ActivePopup::None => footer_total_height, + ActivePopup::Command(c) => c.calculate_required_height(width), + ActivePopup::File(c) => c.calculate_required_height(), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); match &self.active_popup { ActivePopup::Command(popup) => { @@ -1591,16 +1585,15 @@ impl WidgetRef for ChatComposer { } } let style = user_message_style(); - let mut block_rect = composer_rect; - block_rect.y = composer_rect.y.saturating_sub(1); - block_rect.height = composer_rect.height.saturating_add(1); - Block::default().style(style).render_ref(block_rect, buf); - buf.set_span( - composer_rect.x, - composer_rect.y, - &"›".bold(), - composer_rect.width, - ); + Block::default().style(style).render_ref(composer_rect, buf); + if !textarea_rect.is_empty() { + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &"›".bold(), + textarea_rect.width, + ); + } let mut state = self.textarea_state.borrow_mut(); StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); @@ -1692,7 +1685,7 @@ mod tests { let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - composer.render_ref(area, &mut buf); + composer.render(area, &mut buf); let row_to_string = |y: u16| { let mut row = String::new(); @@ -1756,7 +1749,7 @@ mod tests { let height = footer_lines + footer_spacing + 8; let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap(); insta::assert_snapshot!(name, terminal.backend()); } @@ -2276,7 +2269,7 @@ mod tests { } terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); insta::assert_snapshot!(name, terminal.backend()); @@ -2302,12 +2295,12 @@ mod tests { // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - let mut terminal = match Terminal::new(TestBackend::new(60, 4)) { + let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { Ok(t) => t, Err(e) => panic!("Failed to create terminal: {e}"), }; terminal - .draw(|f| f.render_widget_ref(composer, f.area())) + .draw(|f| composer.render(f.area(), f.buffer_mut())) .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); // Visual snapshot should show the slash popup with /model as the first entry. diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index 54f474fb1f..e9f0ee697f 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -103,26 +103,6 @@ impl BottomPaneView for CustomPromptView { self.textarea.insert_str(&pasted); true } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; - let top_line_count = 1u16 + extra_offset; - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } } impl Renderable for CustomPromptView { @@ -232,6 +212,26 @@ impl Renderable for CustomPromptView { ); } } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if area.height < 2 || area.width <= 2 { + return None; + } + let text_area_height = self.input_height(area.width).saturating_sub(1); + if text_area_height == 0 { + return None; + } + let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; + let top_line_count = 1u16 + extra_offset; + let textarea_rect = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(top_line_count).saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } } impl CustomPromptView { diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 0d42c0bebc..a8476df0ab 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -163,6 +163,12 @@ impl BottomPaneView for FeedbackNoteView { self.textarea.insert_str(&pasted); true } +} + +impl Renderable for FeedbackNoteView { + fn desired_height(&self, width: u16) -> u16 { + 1u16 + self.input_height(width) + 3u16 + } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.height < 2 || area.width <= 2 { @@ -182,12 +188,6 @@ impl BottomPaneView for FeedbackNoteView { let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) } -} - -impl Renderable for FeedbackNoteView { - fn desired_height(&self, width: u16) -> u16 { - 1u16 + self.input_height(width) + 3u16 - } fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 38a5ecb6ef..685c71c875 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3,19 +3,16 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; -use crate::render::Insets; -use crate::render::RectExt; -use crate::render::renderable::Renderable as _; +use crate::render::renderable::FlexRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; use std::time::Duration; mod approval_overlay; @@ -126,77 +123,6 @@ impl BottomPane { self.request_redraw(); } - pub fn desired_height(&self, width: u16) -> u16 { - let top_margin = 1; - - // Base height depends on whether a modal/overlay is active. - let base = match self.active_view().as_ref() { - Some(view) => view.desired_height(width), - None => { - let status_height = self - .status - .as_ref() - .map_or(0, |status| status.desired_height(width)); - let queue_height = self.queued_user_messages.desired_height(width); - let spacing_height = if status_height == 0 && queue_height == 0 { - 0 - } else { - 1 - }; - self.composer - .desired_height(width) - .saturating_add(spacing_height) - .saturating_add(status_height) - .saturating_add(queue_height) - } - }; - // Account for bottom padding rows. Top spacing is handled in layout(). - base.saturating_add(top_margin) - } - - fn layout(&self, area: Rect) -> [Rect; 2] { - // At small heights, bottom pane takes the entire height. - let top_margin = if area.height <= 1 { 0 } else { 1 }; - - let area = area.inset(Insets::tlbr(top_margin, 0, 0, 0)); - if self.active_view().is_some() { - return [Rect::ZERO, area]; - } - let has_queue = !self.queued_user_messages.messages.is_empty(); - let mut status_height = self - .status - .as_ref() - .map_or(0, |status| status.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - if has_queue && status_height > 1 { - status_height = status_height.saturating_sub(1); - } - let combined_height = status_height - .saturating_add(self.queued_user_messages.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - - let [status_area, _, content_area] = Layout::vertical([ - Constraint::Length(combined_height), - Constraint::Length(if combined_height == 0 { 0 } else { 1 }), - Constraint::Min(1), - ]) - .areas(area); - [status_area, content_area] - } - - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - // Hide the cursor whenever an overlay view is active (e.g. the - // status indicator shown while a task is running, or approval modal). - // In these states the textarea is not interactable, so we should not - // show its caret. - let [_, content] = self.layout(area); - if let Some(view) = self.active_view() { - view.cursor_pos(content) - } else { - self.composer.cursor_pos(content) - } - } - /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. @@ -540,39 +466,36 @@ impl BottomPane { pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + + fn as_renderable(&'_ self) -> RenderableItem<'_> { + if let Some(view) = self.active_view() { + RenderableItem::Borrowed(view) + } else { + let mut flex = FlexRenderable::new(); + if let Some(status) = &self.status { + flex.push(0, RenderableItem::Borrowed(status)); + } + flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); + if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { + flex.push(0, RenderableItem::Owned("".into())); + } + let mut flex2 = FlexRenderable::new(); + flex2.push(1, RenderableItem::Owned(flex.into())); + flex2.push(0, RenderableItem::Borrowed(&self.composer)); + RenderableItem::Owned(Box::new(flex2)) + } + } } -impl WidgetRef for &BottomPane { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [top_area, content_area] = self.layout(area); - - // When a modal view is active, it owns the whole content area. - if let Some(view) = self.active_view() { - view.render(content_area, buf); - } else { - let status_height = self - .status - .as_ref() - .map(|status| status.desired_height(top_area.width).min(top_area.height)) - .unwrap_or(0); - if let Some(status) = &self.status - && status_height > 0 - { - status.render_ref(top_area, buf); - } - - let queue_area = Rect { - x: top_area.x, - y: top_area.y.saturating_add(status_height), - width: top_area.width, - height: top_area.height.saturating_sub(status_height), - }; - if queue_area.height > 0 { - self.queued_user_messages.render(queue_area, buf); - } - - self.composer.render_ref(content_area, buf); - } +impl Renderable for BottomPane { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) } } @@ -599,7 +522,7 @@ mod tests { fn render_snapshot(pane: &BottomPane, area: Rect) -> String { let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); snapshot_buffer(&buf) } @@ -651,7 +574,7 @@ mod tests { // Render and verify the top row does not include an overlay. let area = Rect::new(0, 0, 60, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); let mut r0 = String::new(); for x in 0..area.width { @@ -665,7 +588,7 @@ mod tests { #[test] fn composer_shown_after_denied_while_task_running() { - let (tx_raw, rx) = unbounded_channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, @@ -700,14 +623,14 @@ mod tests { std::thread::sleep(Duration::from_millis(120)); let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - let mut row1 = String::new(); + pane.render(area, &mut buf); + let mut row0 = String::new(); for x in 0..area.width { - row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); + row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); } assert!( - row1.contains("Working"), - "expected Working header after denial on row 1: {row1:?}" + row0.contains("Working"), + "expected Working header after denial on row 0: {row0:?}" ); // Composer placeholder should be visible somewhere below. @@ -726,9 +649,6 @@ mod tests { found_composer, "expected composer visible under status line" ); - - // Drain the channel to avoid unused warnings. - drop(rx); } #[test] @@ -750,16 +670,10 @@ mod tests { // Use a height that allows the status line to be visible above the composer. let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); + pane.render(area, &mut buf); - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header: {row0:?}" - ); + let bufs = snapshot_buffer(&buf); + assert!(bufs.contains("• Working"), "expected Working header"); } #[test] @@ -791,36 +705,6 @@ mod tests { ); } - #[test] - fn status_hidden_when_height_too_small() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - pane.set_task_running(true); - - // Height=2 → composer takes the full space; status collapses when there is no room. - let area2 = Rect::new(0, 0, 20, 2); - assert_snapshot!( - "status_hidden_when_height_too_small_height_2", - render_snapshot(&pane, area2) - ); - - // Height=1 → no padding; single row is the composer (status hidden). - let area1 = Rect::new(0, 0, 20, 1); - assert_snapshot!( - "status_hidden_when_height_too_small_height_1", - render_snapshot(&pane, area1) - ); - } - #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 4253262041..661e82e3ad 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -4,5 +4,6 @@ expression: terminal.backend() --- " " "› /mo " +" " " /model choose what model and reasoning effort to use " " /mention mention a file " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 7abd3baafa..123a5eb3a3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - ↳ Queued follow-up question ⌥ + ↑ edit diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index ecf9258330..86e3da4573 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - • Working (0s • esc to interru diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index aa36a9d45c..27df671e4d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -2,7 +2,6 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - • Working (0s • esc to interrupt) ↳ Queued follow-up question ⌥ + ↑ edit diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap deleted file mode 100644 index 964bf7ed87..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area2)" ---- - -› Ask Codex to do a diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5239c66e80..f4235d5267 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -54,15 +54,11 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use rand::Rng; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -92,8 +88,12 @@ use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; #[cfg(target_os = "windows")] use crate::onboarding::WSL_INSTRUCTIONS; +use crate::render::Insets; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt; +use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -293,6 +293,15 @@ impl From for UserMessage { } } +impl From<&str> for UserMessage { + fn from(text: &str) -> Self { + Self { + text: text.to_string(), + image_paths: Vec::new(), + } + } +} + fn create_initial_user_message(text: String, image_paths: Vec) -> Option { if text.is_empty() && image_paths.is_empty() { None @@ -951,27 +960,6 @@ impl ChatWidget { } } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); - let remaining = area.height.saturating_sub(bottom_min); - - let active_desired = self - .active_cell - .as_ref() - .map_or(0, |c| c.desired_height(area.width) + 1); - let active_height = active_desired.min(remaining); - // Note: no header area; remaining is not used beyond computing active height. - - let header_height = 0u16; - - Layout::vertical([ - Constraint::Length(header_height), - Constraint::Length(active_height), - Constraint::Min(bottom_min), - ]) - .areas(area) - } - pub(crate) fn new( common: ChatWidgetInit, conversation_manager: Arc, @@ -1100,14 +1088,6 @@ impl ChatWidget { } } - pub fn desired_height(&self, width: u16) -> u16 { - self.bottom_pane.desired_height(width) - + self - .active_cell - .as_ref() - .map_or(0, |c| c.desired_height(width) + 1) - } - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1158,12 +1138,7 @@ impl ChatWidget { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; - if self.bottom_pane.is_task_running() { - self.queued_user_messages.push_back(user_message); - self.refresh_queued_user_messages(); - } else { - self.submit_user_message(user_message); - } + self.queue_user_message(user_message); } InputResult::Command(cmd) => { self.dispatch_command(cmd); @@ -1220,7 +1195,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_text_message(INIT_PROMPT.to_string()); + self.submit_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1368,6 +1343,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } + fn queue_user_message(&mut self, user_message: UserMessage) { + if self.bottom_pane.is_task_running() { + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else { + self.submit_user_message(user_message); + } + } + fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { @@ -2322,16 +2306,6 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } - /// Programmatically submit a user text message as if typed in the - /// composer. The text will be added to conversation history and sent to - /// the agent. - pub(crate) fn submit_text_message(&mut self, text: String) { - if text.is_empty() { - return; - } - self.submit_user_message(text.into()); - } - pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() @@ -2357,30 +2331,34 @@ impl ChatWidget { self.token_info = None; } - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, _, bottom_pane_area] = self.layout_areas(area); - self.bottom_pane.cursor_pos(bottom_pane_area) + fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_renderable = match &self.active_cell { + Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), + None => RenderableItem::Owned(Box::new(())), + }; + let mut flex = FlexRenderable::new(); + flex.push(1, active_cell_renderable); + flex.push( + 0, + RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), + ); + RenderableItem::Owned(Box::new(flex)) } } -impl WidgetRef for &ChatWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); - (&self.bottom_pane).render(bottom_pane_area, buf); - if !active_cell_area.is_empty() - && let Some(cell) = &self.active_cell - { - let mut area = active_cell_area; - area.y = area.y.saturating_add(1); - area.height = area.height.saturating_sub(1); - if let Some(exec) = cell.as_any().downcast_ref::() { - exec.render_ref(area, buf); - } else if let Some(tool) = cell.as_any().downcast_ref::() { - tool.render_ref(area, buf); - } - } +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); self.last_rendered_width.set(Some(area.width as usize)); } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable().desired_height(width) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_renderable().cursor_pos(area) + } } enum Notification { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index 51f7901d3a..1e73a237eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index fb07268dd3..7a04b0ef19 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index adb00d4bc6..4487d0652e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,8 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1470 expression: terminal.backend() --- " " " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap index 51f7901d3a..1e73a237eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 4e1b74a2c6..7a04b0ef19 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -1,7 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- " " -"› Ask Codex to do anything " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 45d69ab1bd..4487d0652e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,8 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- " " -"• Thinking (0s • esc to interrupt) " -"› Ask Codex to do anything " +" " +" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap new file mode 100644 index 0000000000..6d9aa515b1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: term.backend().vt100().screen().contents() +--- +• Working (0s • esc to interrupt) + ↳ Hello, world! 0 + ↳ Hello, world! 1 + ↳ Hello, world! 2 + ↳ Hello, world! 3 + ↳ Hello, world! 4 + ↳ Hello, world! 5 + ↳ Hello, world! 6 + ↳ Hello, world! 7 + ↳ Hello, world! 8 + ↳ Hello, world! 9 + ↳ Hello, world! 10 + ↳ Hello, world! 11 + ↳ Hello, world! 12 + ↳ Hello, world! 13 + ↳ Hello, world! 14 + ↳ Hello, world! 15 + ↳ Hello, world! 16 + + +› Ask Codex to do anything + + 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 135dd2491f..50ff0b25a3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -424,7 +424,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { // The approval modal should display the command snippet for user confirmation. let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); // Approve via keyboard and verify a concise decision history line is added @@ -465,7 +465,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_first_line = false; for y in 0..area.height { let mut row = String::new(); @@ -1039,7 +1039,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() { let height = chat.desired_height(width); let area = ratatui::layout::Rect::new(0, 0, width, height); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut blob = String::new(); for y in 0..area.height { @@ -1267,7 +1267,7 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); - (chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); for y in 0..area.height { let mut row = String::new(); for x in 0..area.width { @@ -1289,7 +1289,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); - (chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut lines: Vec = (0..area.height) .map(|row| { @@ -1701,7 +1701,7 @@ fn approval_modal_exec_snapshot() { terminal.set_viewport_area(viewport); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal"); assert!( terminal @@ -1742,7 +1742,7 @@ fn approval_modal_exec_without_reason_snapshot() { ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); terminal.set_viewport_area(Rect::new(0, 0, 80, height)); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal (no reason)"); assert_snapshot!( "approval_modal_exec_no_reason", @@ -1781,7 +1781,7 @@ fn approval_modal_patch_snapshot() { ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); terminal.set_viewport_area(Rect::new(0, 0, 80, height)); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw patch approval modal"); assert_snapshot!( "approval_modal_patch", @@ -1873,7 +1873,7 @@ fn ui_snapshots_small_heights_idle() { let name = format!("chat_small_idle_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw chat idle"); assert_snapshot!(name, terminal.backend()); } @@ -1903,7 +1903,7 @@ fn ui_snapshots_small_heights_task_running() { let name = format!("chat_small_running_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw chat running"); assert_snapshot!(name, terminal.backend()); } @@ -1953,7 +1953,7 @@ fn status_widget_and_approval_modal_snapshot() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) .expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status + approval modal"); assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); } @@ -1982,7 +1982,7 @@ fn status_widget_active_snapshot() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) .expect("create terminal"); terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) + .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status widget"); assert_snapshot!("status_widget_active", terminal.backend()); } @@ -2017,7 +2017,7 @@ fn apply_patch_events_emit_history_cells() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_summary = false; for y in 0..area.height { let mut row = String::new(); @@ -2304,7 +2304,7 @@ fn apply_patch_untrusted_shows_approval_modal() { // Render and ensure the approval modal title is present let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut contains_title = false; for y in 0..area.height { @@ -2358,7 +2358,7 @@ fn apply_patch_request_shows_diff_summary() { let area = Rect::new(0, 0, 80, chat.desired_height(80)); let mut buf = ratatui::buffer::Buffer::empty(area); - (&chat).render_ref(area, &mut buf); + chat.render(area, &mut buf); let mut saw_header = false; let mut saw_line1 = false; @@ -2683,7 +2683,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { } term.draw(|f| { - (&chat).render_ref(f.area(), f.buffer_mut()); + chat.render(f.area(), f.buffer_mut()); }) .unwrap(); @@ -2781,3 +2781,28 @@ printf 'fenced within fenced\n' assert_snapshot!(term.backend().vt100().screen().contents()); } + +#[test] +fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: None, + }), + }); + for i in 0..30 { + chat.queue_user_message(format!("Hello, world! {i}").into()); + } + let width: u16 = 80; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let desired_height = chat.desired_height(width).min(height); + term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); + term.draw(|f| { + chat.render(f.area(), f.buffer_mut()); + }) + .unwrap(); + assert_snapshot!(term.backend().vt100().screen().contents()); +} diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 665fad15f7..24c5be597b 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -67,7 +67,7 @@ impl From for Box { rows.push(Box::new(path)); rows.push(Box::new(RtLine::from(""))); rows.push(Box::new(InsetRenderable::new( - row.change, + Box::new(row.change) as Box, Insets::tlbr(0, 2, 0, 0), ))); } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 4f21f96e90..3ccc0527e9 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -19,9 +19,6 @@ use itertools::Itertools; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; @@ -205,31 +202,6 @@ impl HistoryCell for ExecCell { } } -impl WidgetRef for &ExecCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let content_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height, - }; - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Paragraph::new(Text::from(rendered)) - .wrap(Wrap { trim: false }) - .render(content_area, buf); - } -} - impl ExecCell { fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 73d61dc4e0..27f8220ac8 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -11,6 +11,7 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; @@ -45,7 +46,6 @@ use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::any::Any; use std::collections::HashMap; @@ -99,6 +99,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { } } +impl Renderable for Box { + fn render(&self, area: Rect, buf: &mut Buffer) { + let lines = self.display_lines(area.width); + let y = if area.height == 0 { + 0 + } else { + let overflow = lines.len().saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Paragraph::new(Text::from(lines)) + .scroll((y, 0)) + .render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + HistoryCell::desired_height(self.as_ref(), width) + } +} + impl dyn HistoryCell { pub(crate) fn as_any(&self) -> &dyn Any { self @@ -929,23 +947,6 @@ impl HistoryCell for McpToolCallCell { } } -impl WidgetRef for &McpToolCallCell { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 { - return; - } - let lines = self.display_lines(area.width); - let max_rows = area.height as usize; - let rendered = if lines.len() > max_rows { - lines[lines.len() - max_rows..].to_vec() - } else { - lines - }; - - Text::from(rendered).render(area, buf); - } -} - pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 457e37fe06..2a80c087ee 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -7,13 +7,13 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; use std::time::Duration; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; +use crate::render::renderable::Renderable; /// Action returned from feeding a key event into the ComposerInput. pub enum ComposerAction { @@ -94,7 +94,7 @@ impl ComposerInput { /// Render the input into the provided buffer at `area`. pub fn render_ref(&self, area: Rect, buf: &mut Buffer) { - WidgetRef::render_ref(&self.inner, area, buf); + self.inner.render(area, buf); } /// Return true if a paste-burst detection is currently active. diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index df06449555..8581468c0a 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -13,9 +13,49 @@ use crate::render::RectExt as _; pub trait Renderable { fn render(&self, area: Rect, buf: &mut Buffer); fn desired_height(&self, width: u16) -> u16; + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + None + } } -impl From for Box { +pub enum RenderableItem<'a> { + Owned(Box), + Borrowed(&'a dyn Renderable), +} + +impl<'a> Renderable for RenderableItem<'a> { + fn render(&self, area: Rect, buf: &mut Buffer) { + match self { + RenderableItem::Owned(child) => child.render(area, buf), + RenderableItem::Borrowed(child) => child.render(area, buf), + } + } + + fn desired_height(&self, width: u16) -> u16 { + match self { + RenderableItem::Owned(child) => child.desired_height(width), + RenderableItem::Borrowed(child) => child.desired_height(width), + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + match self { + RenderableItem::Owned(child) => child.cursor_pos(area), + RenderableItem::Borrowed(child) => child.cursor_pos(area), + } + } +} + +impl<'a> From> for RenderableItem<'a> { + fn from(value: Box) -> Self { + RenderableItem::Owned(value) + } +} + +impl<'a, R> From for Box +where + R: Renderable + 'a, +{ fn from(value: R) -> Self { Box::new(value) } @@ -98,11 +138,11 @@ impl Renderable for Arc { } } -pub struct ColumnRenderable { - children: Vec>, +pub struct ColumnRenderable<'a> { + children: Vec>, } -impl Renderable for ColumnRenderable { +impl Renderable for ColumnRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let mut y = area.y; for child in &self.children { @@ -121,29 +161,166 @@ impl Renderable for ColumnRenderable { .map(|child| child.desired_height(width)) .sum() } + + /// Returns the cursor position of the first child that has a cursor position, offset by the + /// child's position in the column. + /// + /// It is generally assumed that either zero or one child will have a cursor position. + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let mut y = area.y; + for child in &self.children { + let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width)) + .intersection(area); + if !child_area.is_empty() + && let Some((px, py)) = child.cursor_pos(child_area) + { + return Some((px, py)); + } + y += child_area.height; + } + None + } } -impl ColumnRenderable { +impl<'a> ColumnRenderable<'a> { pub fn new() -> Self { - Self::with(vec![]) + Self { children: vec![] } } - pub fn with(children: impl IntoIterator>) -> Self { + pub fn with(children: I) -> Self + where + I: IntoIterator, + T: Into>, + { Self { - children: children.into_iter().collect(), + children: children.into_iter().map(Into::into).collect(), } } - pub fn push(&mut self, child: impl Into>) { - self.children.push(child.into()); + pub fn push(&mut self, child: impl Into>) { + self.children.push(RenderableItem::Owned(child.into())); + } + + #[allow(dead_code)] + pub fn push_ref(&mut self, child: &'a R) + where + R: Renderable + 'a, + { + self.children + .push(RenderableItem::Borrowed(child as &'a dyn Renderable)); } } -pub struct RowRenderable { - children: Vec<(u16, Box)>, +pub struct FlexChild<'a> { + flex: i32, + child: RenderableItem<'a>, } -impl Renderable for RowRenderable { +pub struct FlexRenderable<'a> { + children: Vec>, +} + +/// Lays out children in a column, with the ability to specify a flex factor for each child. +/// +/// Children with flex factor > 0 will be allocated the remaining space after the non-flex children, +/// proportional to the flex factor. +impl<'a> FlexRenderable<'a> { + pub fn new() -> Self { + Self { children: vec![] } + } + + pub fn push(&mut self, flex: i32, child: impl Into>) { + self.children.push(FlexChild { + flex, + child: child.into(), + }); + } + + /// Loosely inspired by Flutter's Flex widget. + /// + /// Ref https://github.com/flutter/flutter/blob/3fd81edbf1e015221e143c92b2664f4371bdc04a/packages/flutter/lib/src/rendering/flex.dart#L1205-L1209 + fn allocate(&self, area: Rect) -> Vec { + let mut allocated_rects = Vec::with_capacity(self.children.len()); + let mut child_sizes = vec![0; self.children.len()]; + let mut allocated_size = 0; + let mut total_flex = 0; + + // 1. Allocate space to non-flex children. + let max_size = area.height; + let mut last_flex_child_idx = 0; + for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { + if *flex > 0 { + total_flex += flex; + last_flex_child_idx = i; + } else { + child_sizes[i] = child + .desired_height(area.width) + .min(max_size.saturating_sub(allocated_size)); + allocated_size += child_sizes[i]; + } + } + let free_space = max_size.saturating_sub(allocated_size); + // 2. Allocate space to flex children, proportional to their flex factor. + let mut allocated_flex_space = 0; + if total_flex > 0 { + let space_per_flex = free_space / total_flex as u16; + for (i, FlexChild { flex, child }) in self.children.iter().enumerate() { + if *flex > 0 { + // Last flex child gets all the remaining space, to prevent a rounding error + // from not allocating all the space. + let max_child_extent = if i == last_flex_child_idx { + free_space - allocated_flex_space + } else { + space_per_flex * *flex as u16 + }; + let child_size = child.desired_height(area.width).min(max_child_extent); + child_sizes[i] = child_size; + allocated_size += child_size; + allocated_flex_space += child_size; + } + } + } + + let mut y = area.y; + for size in child_sizes { + let child_area = Rect::new(area.x, y, area.width, size); + allocated_rects.push(child_area); + y += child_area.height; + } + allocated_rects + } +} + +impl<'a> Renderable for FlexRenderable<'a> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .for_each(|(rect, child)| { + child.child.render(rect, buf); + }); + } + + fn desired_height(&self, width: u16) -> u16 { + self.allocate(Rect::new(0, 0, width, u16::MAX)) + .last() + .map(|rect| rect.bottom()) + .unwrap_or(0) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .find_map(|(rect, child)| child.child.cursor_pos(rect)) + } +} + +pub struct RowRenderable<'a> { + children: Vec<(u16, RenderableItem<'a>)>, +} + +impl Renderable for RowRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let mut x = area.x; for (width, child) in &self.children { @@ -172,24 +349,49 @@ impl Renderable for RowRenderable { } max_height } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let mut x = area.x; + for (width, child) in &self.children { + let available_width = area.width.saturating_sub(x - area.x); + let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height); + if !child_area.is_empty() + && let Some(pos) = child.cursor_pos(child_area) + { + return Some(pos); + } + x = x.saturating_add(*width); + } + None + } } -impl RowRenderable { +impl<'a> RowRenderable<'a> { pub fn new() -> Self { Self { children: vec![] } } pub fn push(&mut self, width: u16, child: impl Into>) { - self.children.push((width, child.into())); + self.children + .push((width, RenderableItem::Owned(child.into()))); + } + + #[allow(dead_code)] + pub fn push_ref(&mut self, width: u16, child: &'a R) + where + R: Renderable + 'a, + { + self.children + .push((width, RenderableItem::Borrowed(child as &'a dyn Renderable))); } } -pub struct InsetRenderable { - child: Box, +pub struct InsetRenderable<'a> { + child: RenderableItem<'a>, insets: Insets, } -impl Renderable for InsetRenderable { +impl<'a> Renderable for InsetRenderable<'a> { fn render(&self, area: Rect, buf: &mut Buffer) { self.child.render(area.inset(self.insets), buf); } @@ -199,10 +401,13 @@ impl Renderable for InsetRenderable { + self.insets.top + self.insets.bottom } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.child.cursor_pos(area.inset(self.insets)) + } } -impl InsetRenderable { - pub fn new(child: impl Into>, insets: Insets) -> Self { +impl<'a> InsetRenderable<'a> { + pub fn new(child: impl Into>, insets: Insets) -> Self { Self { child: child.into(), insets, @@ -210,15 +415,17 @@ impl InsetRenderable { } } -pub trait RenderableExt { - fn inset(self, insets: Insets) -> Box; +pub trait RenderableExt<'a> { + fn inset(self, insets: Insets) -> RenderableItem<'a>; } -impl>> RenderableExt for R { - fn inset(self, insets: Insets) -> Box { - Box::new(InsetRenderable { - child: self.into(), - insets, - }) +impl<'a, R> RenderableExt<'a> for R +where + R: Renderable + 'a, +{ + fn inset(self, insets: Insets) -> RenderableItem<'a> { + let child: RenderableItem<'a> = + RenderableItem::Owned(Box::new(self) as Box); + RenderableItem::Owned(Box::new(InsetRenderable { child, insets })) } } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 7369229556..ea7627a4f5 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -16,6 +16,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; +use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; @@ -62,10 +63,6 @@ impl StatusIndicatorWidget { } } - pub fn desired_height(&self, _width: u16) -> u16 { - 1 - } - pub(crate) fn interrupt(&self) { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); } @@ -75,15 +72,15 @@ impl StatusIndicatorWidget { self.header = header; } - pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { - self.show_interrupt_hint = visible; - } - #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + self.show_interrupt_hint = visible; + } + #[cfg(test)] pub(crate) fn interrupt_hint_visible(&self) -> bool { self.show_interrupt_hint @@ -131,8 +128,12 @@ impl StatusIndicatorWidget { } } -impl WidgetRef for StatusIndicatorWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { +impl Renderable for StatusIndicatorWidget { + fn desired_height(&self, _width: u16) -> u16 { + 1 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } @@ -200,7 +201,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) + .draw(|f| w.render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } @@ -214,7 +215,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) + .draw(|f| w.render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } From 1a89f700155a4968590970263f285d92851e6ac7 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 5 Nov 2025 10:49:35 -0800 Subject: [PATCH 034/122] refactor Conversation history file into its own directory (#6229) This is just a refactor of `conversation_history` file by breaking it up into multiple smaller ones with helper. This refactor will help us move more functionality related to context management here. in a clean way. --- codex-rs/core/src/codex.rs | 8 +- codex-rs/core/src/context_manager/history.rs | 174 +++ .../core/src/context_manager/history_tests.rs | 841 ++++++++++ codex-rs/core/src/context_manager/mod.rs | 6 + .../core/src/context_manager/normalize.rs | 213 +++ codex-rs/core/src/context_manager/truncate.rs | 128 ++ codex-rs/core/src/conversation_history.rs | 1349 ----------------- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/state/session.rs | 8 +- codex-rs/core/src/tools/mod.rs | 2 +- 10 files changed, 1372 insertions(+), 1359 deletions(-) create mode 100644 codex-rs/core/src/context_manager/history.rs create mode 100644 codex-rs/core/src/context_manager/history_tests.rs create mode 100644 codex-rs/core/src/context_manager/mod.rs create mode 100644 codex-rs/core/src/context_manager/normalize.rs create mode 100644 codex-rs/core/src/context_manager/truncate.rs delete mode 100644 codex-rs/core/src/conversation_history.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b2ab5c1022..5bf826fc89 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -58,7 +58,7 @@ use crate::client_common::ResponseEvent; use crate::config::Config; use crate::config::types::McpServerTransportConfig; use crate::config::types::ShellEnvironmentPolicy; -use crate::conversation_history::ConversationHistory; +use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -945,7 +945,7 @@ impl Session { turn_context: &TurnContext, rollout_items: &[RolloutItem], ) -> Vec { - let mut history = ConversationHistory::new(); + let mut history = ContextManager::new(); for item in rollout_items { match item { RolloutItem::ResponseItem(response_item) => { @@ -1032,7 +1032,7 @@ impl Session { } } - pub(crate) async fn clone_history(&self) -> ConversationHistory { + pub(crate) async fn clone_history(&self) -> ContextManager { let state = self.state.lock().await; state.clone_history() } @@ -2834,7 +2834,7 @@ mod tests { turn_context: &TurnContext, ) -> (Vec, Vec) { let mut rollout_items = Vec::new(); - let mut live_history = ConversationHistory::new(); + let mut live_history = ContextManager::new(); let initial_context = session.build_initial_context(turn_context); for item in &initial_context { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs new file mode 100644 index 0000000000..da9157124b --- /dev/null +++ b/codex-rs/core/src/context_manager/history.rs @@ -0,0 +1,174 @@ +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use std::ops::Deref; + +use crate::context_manager::normalize; +use crate::context_manager::truncate::format_output_for_model_body; +use crate::context_manager::truncate::globally_truncate_function_output_items; + +/// Transcript of conversation history +#[derive(Debug, Clone, Default)] +pub(crate) struct ContextManager { + /// The oldest items are at the beginning of the vector. + items: Vec, + token_info: Option, +} + +impl ContextManager { + pub(crate) fn new() -> Self { + Self { + items: Vec::new(), + token_info: TokenUsageInfo::new_or_append(&None, &None, None), + } + } + + pub(crate) fn token_info(&self) -> Option { + self.token_info.clone() + } + + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { + match &mut self.token_info { + Some(info) => info.fill_to_context_window(context_window), + None => { + self.token_info = Some(TokenUsageInfo::full_context_window(context_window)); + } + } + } + + /// `items` is ordered from oldest to newest. + pub(crate) fn record_items(&mut self, items: I) + where + I: IntoIterator, + I::Item: std::ops::Deref, + { + for item in items { + let item_ref = item.deref(); + let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. }); + if !is_api_message(item_ref) && !is_ghost_snapshot { + continue; + } + + let processed = Self::process_item(&item); + self.items.push(processed); + } + } + + pub(crate) fn get_history(&mut self) -> Vec { + self.normalize_history(); + self.contents() + } + + // Returns the history prepared for sending to the model. + // With extra response items filtered out and GhostCommits removed. + pub(crate) fn get_history_for_prompt(&mut self) -> Vec { + let mut history = self.get_history(); + Self::remove_ghost_snapshots(&mut history); + history + } + + pub(crate) fn remove_first_item(&mut self) { + if !self.items.is_empty() { + // Remove the oldest item (front of the list). Items are ordered from + // oldest → newest, so index 0 is the first entry recorded. + let removed = self.items.remove(0); + // If the removed item participates in a call/output pair, also remove + // its corresponding counterpart to keep the invariants intact without + // running a full normalization pass. + normalize::remove_corresponding_for(&mut self.items, &removed); + } + } + + pub(crate) fn replace(&mut self, items: Vec) { + self.items = items; + } + + pub(crate) fn update_token_info( + &mut self, + usage: &TokenUsage, + model_context_window: Option, + ) { + self.token_info = TokenUsageInfo::new_or_append( + &self.token_info, + &Some(usage.clone()), + model_context_window, + ); + } + + /// This function enforces a couple of invariants on the in-memory history: + /// 1. every call (function/custom) has a corresponding output entry + /// 2. every output has a corresponding call entry + fn normalize_history(&mut self) { + // all function/tool calls must have a corresponding output + normalize::ensure_call_outputs_present(&mut self.items); + + // all outputs must have a corresponding function/tool call + normalize::remove_orphan_outputs(&mut self.items); + } + + /// Returns a clone of the contents in the transcript. + fn contents(&self) -> Vec { + self.items.clone() + } + + fn remove_ghost_snapshots(items: &mut Vec) { + items.retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. })); + } + + fn process_item(item: &ResponseItem) -> ResponseItem { + match item { + ResponseItem::FunctionCallOutput { call_id, output } => { + let truncated = format_output_for_model_body(output.content.as_str()); + let truncated_items = output + .content_items + .as_ref() + .map(|items| globally_truncate_function_output_items(items)); + ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: FunctionCallOutputPayload { + content: truncated, + content_items: truncated_items, + success: output.success, + }, + } + } + ResponseItem::CustomToolCallOutput { call_id, output } => { + let truncated = format_output_for_model_body(output); + ResponseItem::CustomToolCallOutput { + call_id: call_id.clone(), + output: truncated, + } + } + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Other => item.clone(), + } + } +} + +/// API messages include every non-system item (user/assistant messages, reasoning, +/// tool calls, tool outputs, shell calls, and web-search calls). +fn is_api_message(message: &ResponseItem) -> bool { + match message { + ResponseItem::Message { role, .. } => role.as_str() != "system", + ResponseItem::FunctionCallOutput { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::WebSearchCall { .. } => true, + ResponseItem::GhostSnapshot { .. } => false, + ResponseItem::Other => false, + } +} + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs new file mode 100644 index 0000000000..ae3e0368fa --- /dev/null +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -0,0 +1,841 @@ +use super::*; +use crate::context_manager::truncate; +use codex_git::GhostCommit; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::LocalShellExecAction; +use codex_protocol::models::LocalShellStatus; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ReasoningItemReasoningSummary; +use pretty_assertions::assert_eq; +use regex_lite::Regex; + +fn assistant_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } +} + +fn create_history_with_items(items: Vec) -> ContextManager { + let mut h = ContextManager::new(); + h.record_items(items.iter()); + h +} + +fn user_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } +} + +fn reasoning_msg(text: &str) -> ResponseItem { + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: text.to_string(), + }]), + encrypted_content: None, + } +} + +#[test] +fn filters_non_api_messages() { + let mut h = ContextManager::default(); + // System message is not API messages; Other is ignored. + let system = ResponseItem::Message { + id: None, + role: "system".to_string(), + content: vec![ContentItem::OutputText { + text: "ignored".to_string(), + }], + }; + let reasoning = reasoning_msg("thinking..."); + h.record_items([&system, &reasoning, &ResponseItem::Other]); + + // User and assistant should be retained. + let u = user_msg("hi"); + let a = assistant_msg("hello"); + h.record_items([&u, &a]); + + let items = h.contents(); + assert_eq!( + items, + vec![ + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: "thinking...".to_string(), + }]), + encrypted_content: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::OutputText { + text: "hi".to_string() + }] + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "hello".to_string() + }] + } + ] + ); +} + +#[test] +fn get_history_for_prompt_drops_ghost_commits() { + let items = vec![ResponseItem::GhostSnapshot { + ghost_commit: GhostCommit::new("ghost-1".to_string(), None, Vec::new(), Vec::new()), + }]; + let mut history = create_history_with_items(items); + let filtered = history.get_history_for_prompt(); + assert_eq!(filtered, vec![]); +} + +#[test] +fn remove_first_item_removes_matching_output_for_function_call() { + let items = vec![ + ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + ]; + let mut h = create_history_with_items(items); + h.remove_first_item(); + assert_eq!(h.contents(), vec![]); +} + +#[test] +fn remove_first_item_removes_matching_call_for_output() { + let items = vec![ + ResponseItem::FunctionCallOutput { + call_id: "call-2".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-2".to_string(), + }, + ]; + let mut h = create_history_with_items(items); + h.remove_first_item(); + assert_eq!(h.contents(), vec![]); +} + +#[test] +fn remove_first_item_handles_local_shell_pair() { + let items = vec![ + ResponseItem::LocalShellCall { + id: None, + call_id: Some("call-3".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hi".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-3".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + ]; + let mut h = create_history_with_items(items); + h.remove_first_item(); + assert_eq!(h.contents(), vec![]); +} + +#[test] +fn remove_first_item_handles_custom_tool_pair() { + let items = vec![ + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "tool-1".to_string(), + name: "my_tool".to_string(), + input: "{}".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "tool-1".to_string(), + output: "ok".to_string(), + }, + ]; + let mut h = create_history_with_items(items); + h.remove_first_item(); + assert_eq!(h.contents(), vec![]); +} + +#[test] +fn normalization_retains_local_shell_outputs() { + let items = vec![ + ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell-1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hi".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ResponseItem::FunctionCallOutput { + call_id: "shell-1".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + ]; + + let mut history = create_history_with_items(items.clone()); + let normalized = history.get_history(); + assert_eq!(normalized, items); +} + +#[test] +fn record_items_truncates_function_call_output_content() { + let mut history = ContextManager::new(); + let long_line = "a very long line to trigger truncation\n"; + let long_output = long_line.repeat(2_500); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-100".to_string(), + output: FunctionCallOutputPayload { + content: long_output.clone(), + success: Some(true), + ..Default::default() + }, + }; + + history.record_items([&item]); + + assert_eq!(history.items.len(), 1); + match &history.items[0] { + ResponseItem::FunctionCallOutput { output, .. } => { + assert_ne!(output.content, long_output); + assert!( + output.content.starts_with("Total output lines:"), + "expected truncated summary, got {}", + output.content + ); + } + other => panic!("unexpected history item: {other:?}"), + } +} + +#[test] +fn record_items_truncates_custom_tool_call_output_content() { + let mut history = ContextManager::new(); + let line = "custom output that is very long\n"; + let long_output = line.repeat(2_500); + let item = ResponseItem::CustomToolCallOutput { + call_id: "tool-200".to_string(), + output: long_output.clone(), + }; + + history.record_items([&item]); + + assert_eq!(history.items.len(), 1); + match &history.items[0] { + ResponseItem::CustomToolCallOutput { output, .. } => { + assert_ne!(output, &long_output); + assert!( + output.starts_with("Total output lines:"), + "expected truncated summary, got {output}" + ); + } + other => panic!("unexpected history item: {other:?}"), + } +} + +fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) { + let pattern = truncated_message_pattern(line, total_lines); + let regex = Regex::new(&pattern).unwrap_or_else(|err| { + panic!("failed to compile regex {pattern}: {err}"); + }); + let captures = regex + .captures(message) + .unwrap_or_else(|| panic!("message failed to match pattern {pattern}: {message}")); + let body = captures + .name("body") + .expect("missing body capture") + .as_str(); + assert!( + body.len() <= truncate::MODEL_FORMAT_MAX_BYTES, + "body exceeds byte limit: {} bytes", + body.len() + ); +} + +fn truncated_message_pattern(line: &str, total_lines: usize) -> String { + let head_take = truncate::MODEL_FORMAT_HEAD_LINES.min(total_lines); + let tail_take = truncate::MODEL_FORMAT_TAIL_LINES.min(total_lines.saturating_sub(head_take)); + let omitted = total_lines.saturating_sub(head_take + tail_take); + let escaped_line = regex_lite::escape(line); + if omitted == 0 { + return format!( + r"(?s)^Total output lines: {total_lines}\n\n(?P{escaped_line}.*\n\[\.{{3}} output truncated to fit {max_bytes} bytes \.{{3}}]\n\n.*)$", + max_bytes = truncate::MODEL_FORMAT_MAX_BYTES, + ); + } + format!( + r"(?s)^Total output lines: {total_lines}\n\n(?P{escaped_line}.*\n\[\.{{3}} omitted {omitted} of {total_lines} lines \.{{3}}]\n\n.*)$", + ) +} + +#[test] +fn format_exec_output_truncates_large_error() { + let line = "very long execution error line that should trigger truncation\n"; + let large_error = line.repeat(2_500); // way beyond both byte and line limits + + let truncated = truncate::format_output_for_model_body(&large_error); + + let total_lines = large_error.lines().count(); + assert_truncated_message_matches(&truncated, line, total_lines); + assert_ne!(truncated, large_error); +} + +#[test] +fn format_exec_output_marks_byte_truncation_without_omitted_lines() { + let long_line = "a".repeat(truncate::MODEL_FORMAT_MAX_BYTES + 50); + let truncated = truncate::format_output_for_model_body(&long_line); + + assert_ne!(truncated, long_line); + let marker_line = format!( + "[... output truncated to fit {} bytes ...]", + truncate::MODEL_FORMAT_MAX_BYTES + ); + assert!( + truncated.contains(&marker_line), + "missing byte truncation marker: {truncated}" + ); + assert!( + !truncated.contains("omitted"), + "line omission marker should not appear when no lines were dropped: {truncated}" + ); +} + +#[test] +fn format_exec_output_returns_original_when_within_limits() { + let content = "example output\n".repeat(10); + + assert_eq!(truncate::format_output_for_model_body(&content), content); +} + +#[test] +fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() { + let total_lines = truncate::MODEL_FORMAT_MAX_LINES + 100; + let content: String = (0..total_lines) + .map(|idx| format!("line-{idx}\n")) + .collect(); + + let truncated = truncate::format_output_for_model_body(&content); + let omitted = total_lines - truncate::MODEL_FORMAT_MAX_LINES; + let expected_marker = format!("[... omitted {omitted} of {total_lines} lines ...]"); + + assert!( + truncated.contains(&expected_marker), + "missing omitted marker: {truncated}" + ); + assert!( + truncated.contains("line-0\n"), + "expected head line to remain: {truncated}" + ); + + let last_line = format!("line-{}\n", total_lines - 1); + assert!( + truncated.contains(&last_line), + "expected tail line to remain: {truncated}" + ); +} + +#[test] +fn format_exec_output_prefers_line_marker_when_both_limits_exceeded() { + let total_lines = truncate::MODEL_FORMAT_MAX_LINES + 42; + let long_line = "x".repeat(256); + let content: String = (0..total_lines) + .map(|idx| format!("line-{idx}-{long_line}\n")) + .collect(); + + let truncated = truncate::format_output_for_model_body(&content); + + assert!( + truncated.contains("[... omitted 42 of 298 lines ...]"), + "expected omitted marker when line count exceeds limit: {truncated}" + ); + assert!( + !truncated.contains("output truncated to fit"), + "line omission marker should take precedence over byte marker: {truncated}" + ); +} + +#[test] +fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { + // Arrange: several text items, none exceeding per-item limit, but total exceeds budget. + let budget = truncate::MODEL_FORMAT_MAX_BYTES; + let t1_len = (budget / 2).saturating_sub(10); + let t2_len = (budget / 2).saturating_sub(10); + let remaining_after_t1_t2 = budget.saturating_sub(t1_len + t2_len); + let t3_len = 50; // gets truncated to remaining_after_t1_t2 + let t4_len = 5; // omitted + let t5_len = 7; // omitted + + let t1 = "a".repeat(t1_len); + let t2 = "b".repeat(t2_len); + let t3 = "c".repeat(t3_len); + let t4 = "d".repeat(t4_len); + let t5 = "e".repeat(t5_len); + + let item = ResponseItem::FunctionCallOutput { + call_id: "call-omit".to_string(), + output: FunctionCallOutputPayload { + content: "irrelevant".to_string(), + content_items: Some(vec![ + FunctionCallOutputContentItem::InputText { text: t1 }, + FunctionCallOutputContentItem::InputText { text: t2 }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:mid".to_string(), + }, + FunctionCallOutputContentItem::InputText { text: t3 }, + FunctionCallOutputContentItem::InputText { text: t4 }, + FunctionCallOutputContentItem::InputText { text: t5 }, + ]), + success: Some(true), + }, + }; + + let mut history = ContextManager::new(); + history.record_items([&item]); + assert_eq!(history.items.len(), 1); + let json = serde_json::to_value(&history.items[0]).expect("serialize to json"); + + let output = json + .get("output") + .expect("output field") + .as_array() + .expect("array output"); + + // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. + assert_eq!(output.len(), 5); + + let first = output[0].as_object().expect("first obj"); + assert_eq!(first.get("type").unwrap(), "input_text"); + let first_text = first.get("text").unwrap().as_str().unwrap(); + assert_eq!(first_text.len(), t1_len); + + let second = output[1].as_object().expect("second obj"); + assert_eq!(second.get("type").unwrap(), "input_text"); + let second_text = second.get("text").unwrap().as_str().unwrap(); + assert_eq!(second_text.len(), t2_len); + + assert_eq!( + output[2], + serde_json::json!({"type": "input_image", "image_url": "img:mid"}) + ); + + let fourth = output[3].as_object().expect("fourth obj"); + assert_eq!(fourth.get("type").unwrap(), "input_text"); + let fourth_text = fourth.get("text").unwrap().as_str().unwrap(); + assert_eq!(fourth_text.len(), remaining_after_t1_t2); + + let summary = output[4].as_object().expect("summary obj"); + assert_eq!(summary.get("type").unwrap(), "input_text"); + let summary_text = summary.get("text").unwrap().as_str().unwrap(); + assert!(summary_text.contains("omitted 2 text items")); +} + +//TODO(aibrahim): run CI in release mode. +#[cfg(not(debug_assertions))] +#[test] +fn normalize_adds_missing_output_for_function_call() { + let items = vec![ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-x".to_string(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!( + h.contents(), + vec![ + ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-x".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-x".to_string(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + ] + ); +} + +#[cfg(not(debug_assertions))] +#[test] +fn normalize_adds_missing_output_for_custom_tool_call() { + let items = vec![ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "tool-x".to_string(), + name: "custom".to_string(), + input: "{}".to_string(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!( + h.contents(), + vec![ + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "tool-x".to_string(), + name: "custom".to_string(), + input: "{}".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "tool-x".to_string(), + output: "aborted".to_string(), + }, + ] + ); +} + +#[cfg(not(debug_assertions))] +#[test] +fn normalize_adds_missing_output_for_local_shell_call_with_id() { + let items = vec![ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell-1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hi".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!( + h.contents(), + vec![ + ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell-1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hi".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ResponseItem::FunctionCallOutput { + call_id: "shell-1".to_string(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + ] + ); +} + +#[cfg(not(debug_assertions))] +#[test] +fn normalize_removes_orphan_function_call_output() { + let items = vec![ResponseItem::FunctionCallOutput { + call_id: "orphan-1".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!(h.contents(), vec![]); +} + +#[cfg(not(debug_assertions))] +#[test] +fn normalize_removes_orphan_custom_tool_call_output() { + let items = vec![ResponseItem::CustomToolCallOutput { + call_id: "orphan-2".to_string(), + output: "ok".to_string(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!(h.contents(), vec![]); +} + +#[cfg(not(debug_assertions))] +#[test] +fn normalize_mixed_inserts_and_removals() { + let items = vec![ + // Will get an inserted output + ResponseItem::FunctionCall { + id: None, + name: "f1".to_string(), + arguments: "{}".to_string(), + call_id: "c1".to_string(), + }, + // Orphan output that should be removed + ResponseItem::FunctionCallOutput { + call_id: "c2".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + // Will get an inserted custom tool output + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "t1".to_string(), + name: "tool".to_string(), + input: "{}".to_string(), + }, + // Local shell call also gets an inserted function call output + ResponseItem::LocalShellCall { + id: None, + call_id: Some("s1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ]; + let mut h = create_history_with_items(items); + + h.normalize_history(); + + assert_eq!( + h.contents(), + vec![ + ResponseItem::FunctionCall { + id: None, + name: "f1".to_string(), + arguments: "{}".to_string(), + call_id: "c1".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "c1".to_string(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "t1".to_string(), + name: "tool".to_string(), + input: "{}".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "t1".to_string(), + output: "aborted".to_string(), + }, + ResponseItem::LocalShellCall { + id: None, + call_id: Some("s1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ResponseItem::FunctionCallOutput { + call_id: "s1".to_string(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + ] + ); +} + +// In debug builds we panic on normalization errors instead of silently fixing them. +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_adds_missing_output_for_function_call_panics_in_debug() { + let items = vec![ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-x".to_string(), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { + let items = vec![ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "tool-x".to_string(), + name: "custom".to_string(), + input: "{}".to_string(), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() { + let items = vec![ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell-1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hi".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_removes_orphan_function_call_output_panics_in_debug() { + let items = vec![ResponseItem::FunctionCallOutput { + call_id: "orphan-1".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { + let items = vec![ResponseItem::CustomToolCallOutput { + call_id: "orphan-2".to_string(), + output: "ok".to_string(), + }]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic] +fn normalize_mixed_inserts_and_removals_panics_in_debug() { + let items = vec![ + ResponseItem::FunctionCall { + id: None, + name: "f1".to_string(), + arguments: "{}".to_string(), + call_id: "c1".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "c2".to_string(), + output: FunctionCallOutputPayload { + content: "ok".to_string(), + ..Default::default() + }, + }, + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "t1".to_string(), + name: "tool".to_string(), + input: "{}".to_string(), + }, + ResponseItem::LocalShellCall { + id: None, + call_id: Some("s1".to_string()), + status: LocalShellStatus::Completed, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }), + }, + ]; + let mut h = create_history_with_items(items); + h.normalize_history(); +} diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs new file mode 100644 index 0000000000..8531e443fa --- /dev/null +++ b/codex-rs/core/src/context_manager/mod.rs @@ -0,0 +1,6 @@ +mod history; +mod normalize; +mod truncate; + +pub(crate) use history::ContextManager; +pub(crate) use truncate::format_output_for_model_body; diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs new file mode 100644 index 0000000000..ea38989c90 --- /dev/null +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -0,0 +1,213 @@ +use std::collections::HashSet; + +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItem; + +use crate::util::error_or_panic; + +pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { + // Collect synthetic outputs to insert immediately after their calls. + // Store the insertion position (index of call) alongside the item so + // we can insert in reverse order and avoid index shifting. + let mut missing_outputs_to_insert: Vec<(usize, ResponseItem)> = Vec::new(); + + for (idx, item) in items.iter().enumerate() { + match item { + ResponseItem::FunctionCall { call_id, .. } => { + let has_output = items.iter().any(|i| match i { + ResponseItem::FunctionCallOutput { + call_id: existing, .. + } => existing == call_id, + _ => false, + }); + + if !has_output { + error_or_panic(format!( + "Function call output is missing for call id: {call_id}" + )); + missing_outputs_to_insert.push(( + idx, + ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + )); + } + } + ResponseItem::CustomToolCall { call_id, .. } => { + let has_output = items.iter().any(|i| match i { + ResponseItem::CustomToolCallOutput { + call_id: existing, .. + } => existing == call_id, + _ => false, + }); + + if !has_output { + error_or_panic(format!( + "Custom tool call output is missing for call id: {call_id}" + )); + missing_outputs_to_insert.push(( + idx, + ResponseItem::CustomToolCallOutput { + call_id: call_id.clone(), + output: "aborted".to_string(), + }, + )); + } + } + // LocalShellCall is represented in upstream streams by a FunctionCallOutput + ResponseItem::LocalShellCall { call_id, .. } => { + if let Some(call_id) = call_id.as_ref() { + let has_output = items.iter().any(|i| match i { + ResponseItem::FunctionCallOutput { + call_id: existing, .. + } => existing == call_id, + _ => false, + }); + + if !has_output { + error_or_panic(format!( + "Local shell call output is missing for call id: {call_id}" + )); + missing_outputs_to_insert.push(( + idx, + ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + )); + } + } + } + _ => {} + } + } + + // Insert synthetic outputs in reverse index order to avoid re-indexing. + for (idx, output_item) in missing_outputs_to_insert.into_iter().rev() { + items.insert(idx + 1, output_item); + } +} + +pub(crate) fn remove_orphan_outputs(items: &mut Vec) { + let function_call_ids: HashSet = items + .iter() + .filter_map(|i| match i { + ResponseItem::FunctionCall { call_id, .. } => Some(call_id.clone()), + _ => None, + }) + .collect(); + + let local_shell_call_ids: HashSet = items + .iter() + .filter_map(|i| match i { + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => Some(call_id.clone()), + _ => None, + }) + .collect(); + + let custom_tool_call_ids: HashSet = items + .iter() + .filter_map(|i| match i { + ResponseItem::CustomToolCall { call_id, .. } => Some(call_id.clone()), + _ => None, + }) + .collect(); + + items.retain(|item| match item { + ResponseItem::FunctionCallOutput { call_id, .. } => { + let has_match = + function_call_ids.contains(call_id) || local_shell_call_ids.contains(call_id); + if !has_match { + error_or_panic(format!( + "Orphan function call output for call id: {call_id}" + )); + } + has_match + } + ResponseItem::CustomToolCallOutput { call_id, .. } => { + let has_match = custom_tool_call_ids.contains(call_id); + if !has_match { + error_or_panic(format!( + "Orphan custom tool call output for call id: {call_id}" + )); + } + has_match + } + _ => true, + }); +} + +pub(crate) fn remove_corresponding_for(items: &mut Vec, item: &ResponseItem) { + match item { + ResponseItem::FunctionCall { call_id, .. } => { + remove_first_matching(items, |i| { + matches!( + i, + ResponseItem::FunctionCallOutput { + call_id: existing, .. + } if existing == call_id + ) + }); + } + ResponseItem::FunctionCallOutput { call_id, .. } => { + if let Some(pos) = items.iter().position(|i| { + matches!(i, ResponseItem::FunctionCall { call_id: existing, .. } if existing == call_id) + }) { + items.remove(pos); + } else if let Some(pos) = items.iter().position(|i| { + matches!(i, ResponseItem::LocalShellCall { call_id: Some(existing), .. } if existing == call_id) + }) { + items.remove(pos); + } + } + ResponseItem::CustomToolCall { call_id, .. } => { + remove_first_matching(items, |i| { + matches!( + i, + ResponseItem::CustomToolCallOutput { + call_id: existing, .. + } if existing == call_id + ) + }); + } + ResponseItem::CustomToolCallOutput { call_id, .. } => { + remove_first_matching( + items, + |i| matches!(i, ResponseItem::CustomToolCall { call_id: existing, .. } if existing == call_id), + ); + } + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => { + remove_first_matching(items, |i| { + matches!( + i, + ResponseItem::FunctionCallOutput { + call_id: existing, .. + } if existing == call_id + ) + }); + } + _ => {} + } +} + +fn remove_first_matching(items: &mut Vec, predicate: F) +where + F: Fn(&ResponseItem) -> bool, +{ + if let Some(pos) = items.iter().position(predicate) { + items.remove(pos); + } +} diff --git a/codex-rs/core/src/context_manager/truncate.rs b/codex-rs/core/src/context_manager/truncate.rs new file mode 100644 index 0000000000..65e6dc99a5 --- /dev/null +++ b/codex-rs/core/src/context_manager/truncate.rs @@ -0,0 +1,128 @@ +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_utils_string::take_bytes_at_char_boundary; +use codex_utils_string::take_last_bytes_at_char_boundary; + +// Model-formatting limits: clients get full streams; only content sent to the model is truncated. +pub(crate) const MODEL_FORMAT_MAX_BYTES: usize = 10 * 1024; // 10 KiB +pub(crate) const MODEL_FORMAT_MAX_LINES: usize = 256; // lines +pub(crate) const MODEL_FORMAT_HEAD_LINES: usize = MODEL_FORMAT_MAX_LINES / 2; +pub(crate) const MODEL_FORMAT_TAIL_LINES: usize = MODEL_FORMAT_MAX_LINES - MODEL_FORMAT_HEAD_LINES; // 128 +pub(crate) const MODEL_FORMAT_HEAD_BYTES: usize = MODEL_FORMAT_MAX_BYTES / 2; + +pub(crate) fn globally_truncate_function_output_items( + items: &[FunctionCallOutputContentItem], +) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + let mut remaining = MODEL_FORMAT_MAX_BYTES; + let mut omitted_text_items = 0usize; + + for it in items { + match it { + FunctionCallOutputContentItem::InputText { text } => { + if remaining == 0 { + omitted_text_items += 1; + continue; + } + + let len = text.len(); + if len <= remaining { + out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); + remaining -= len; + } else { + let slice = take_bytes_at_char_boundary(text, remaining); + if !slice.is_empty() { + out.push(FunctionCallOutputContentItem::InputText { + text: slice.to_string(), + }); + } + remaining = 0; + } + } + // todo(aibrahim): handle input images; resize + FunctionCallOutputContentItem::InputImage { image_url } => { + out.push(FunctionCallOutputContentItem::InputImage { + image_url: image_url.clone(), + }); + } + } + } + + if omitted_text_items > 0 { + out.push(FunctionCallOutputContentItem::InputText { + text: format!("[omitted {omitted_text_items} text items ...]"), + }); + } + + out +} + +pub(crate) fn format_output_for_model_body(content: &str) -> String { + // Head+tail truncation for the model: show the beginning and end with an elision. + // Clients still receive full streams; only this formatted summary is capped. + let total_lines = content.lines().count(); + if content.len() <= MODEL_FORMAT_MAX_BYTES && total_lines <= MODEL_FORMAT_MAX_LINES { + return content.to_string(); + } + let output = truncate_formatted_exec_output(content, total_lines); + format!("Total output lines: {total_lines}\n\n{output}") +} + +fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String { + let segments: Vec<&str> = content.split_inclusive('\n').collect(); + let head_take = MODEL_FORMAT_HEAD_LINES.min(segments.len()); + let tail_take = MODEL_FORMAT_TAIL_LINES.min(segments.len().saturating_sub(head_take)); + let omitted = segments.len().saturating_sub(head_take + tail_take); + + let head_slice_end: usize = segments + .iter() + .take(head_take) + .map(|segment| segment.len()) + .sum(); + let tail_slice_start: usize = if tail_take == 0 { + content.len() + } else { + content.len() + - segments + .iter() + .rev() + .take(tail_take) + .map(|segment| segment.len()) + .sum::() + }; + let head_slice = &content[..head_slice_end]; + let tail_slice = &content[tail_slice_start..]; + let truncated_by_bytes = content.len() > MODEL_FORMAT_MAX_BYTES; + // this is a bit wrong. We are counting metadata lines and not just shell output lines. + let marker = if omitted > 0 { + Some(format!( + "\n[... omitted {omitted} of {total_lines} lines ...]\n\n" + )) + } else if truncated_by_bytes { + Some(format!( + "\n[... output truncated to fit {MODEL_FORMAT_MAX_BYTES} bytes ...]\n\n" + )) + } else { + None + }; + + let marker_len = marker.as_ref().map_or(0, String::len); + let base_head_budget = MODEL_FORMAT_HEAD_BYTES.min(MODEL_FORMAT_MAX_BYTES); + let head_budget = base_head_budget.min(MODEL_FORMAT_MAX_BYTES.saturating_sub(marker_len)); + let head_part = take_bytes_at_char_boundary(head_slice, head_budget); + let mut result = String::with_capacity(MODEL_FORMAT_MAX_BYTES.min(content.len())); + + result.push_str(head_part); + if let Some(marker_text) = marker.as_ref() { + result.push_str(marker_text); + } + + let remaining = MODEL_FORMAT_MAX_BYTES.saturating_sub(result.len()); + if remaining == 0 { + return result; + } + + let tail_part = take_last_bytes_at_char_boundary(tail_slice, remaining); + result.push_str(tail_part); + + result +} diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs deleted file mode 100644 index ef493f1458..0000000000 --- a/codex-rs/core/src/conversation_history.rs +++ /dev/null @@ -1,1349 +0,0 @@ -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseItem; - -use crate::util::error_or_panic; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::TokenUsageInfo; -use codex_utils_string::take_bytes_at_char_boundary; -use codex_utils_string::take_last_bytes_at_char_boundary; -use std::ops::Deref; - -// Model-formatting limits: clients get full streams; only content sent to the model is truncated. -pub(crate) const MODEL_FORMAT_MAX_BYTES: usize = 10 * 1024; // 10 KiB -pub(crate) const MODEL_FORMAT_MAX_LINES: usize = 256; // lines -pub(crate) const MODEL_FORMAT_HEAD_LINES: usize = MODEL_FORMAT_MAX_LINES / 2; -pub(crate) const MODEL_FORMAT_TAIL_LINES: usize = MODEL_FORMAT_MAX_LINES - MODEL_FORMAT_HEAD_LINES; // 128 -pub(crate) const MODEL_FORMAT_HEAD_BYTES: usize = MODEL_FORMAT_MAX_BYTES / 2; - -/// Transcript of conversation history -#[derive(Debug, Clone, Default)] -pub(crate) struct ConversationHistory { - /// The oldest items are at the beginning of the vector. - items: Vec, - token_info: Option, -} - -impl ConversationHistory { - pub(crate) fn new() -> Self { - Self { - items: Vec::new(), - token_info: TokenUsageInfo::new_or_append(&None, &None, None), - } - } - - pub(crate) fn token_info(&self) -> Option { - self.token_info.clone() - } - - pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { - match &mut self.token_info { - Some(info) => info.fill_to_context_window(context_window), - None => { - self.token_info = Some(TokenUsageInfo::full_context_window(context_window)); - } - } - } - - /// `items` is ordered from oldest to newest. - pub(crate) fn record_items(&mut self, items: I) - where - I: IntoIterator, - I::Item: std::ops::Deref, - { - for item in items { - let item_ref = item.deref(); - let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. }); - if !is_api_message(item_ref) && !is_ghost_snapshot { - continue; - } - - let processed = Self::process_item(&item); - self.items.push(processed); - } - } - - pub(crate) fn get_history(&mut self) -> Vec { - self.normalize_history(); - self.contents() - } - - // Returns the history prepared for sending to the model. - // With extra response items filtered out and GhostCommits removed. - pub(crate) fn get_history_for_prompt(&mut self) -> Vec { - let mut history = self.get_history(); - Self::remove_ghost_snapshots(&mut history); - history - } - - pub(crate) fn remove_first_item(&mut self) { - if !self.items.is_empty() { - // Remove the oldest item (front of the list). Items are ordered from - // oldest → newest, so index 0 is the first entry recorded. - let removed = self.items.remove(0); - // If the removed item participates in a call/output pair, also remove - // its corresponding counterpart to keep the invariants intact without - // running a full normalization pass. - self.remove_corresponding_for(&removed); - } - } - - pub(crate) fn replace(&mut self, items: Vec) { - self.items = items; - } - - pub(crate) fn update_token_info( - &mut self, - usage: &TokenUsage, - model_context_window: Option, - ) { - self.token_info = TokenUsageInfo::new_or_append( - &self.token_info, - &Some(usage.clone()), - model_context_window, - ); - } - - /// This function enforces a couple of invariants on the in-memory history: - /// 1. every call (function/custom) has a corresponding output entry - /// 2. every output has a corresponding call entry - fn normalize_history(&mut self) { - // all function/tool calls must have a corresponding output - self.ensure_call_outputs_present(); - - // all outputs must have a corresponding function/tool call - self.remove_orphan_outputs(); - } - - /// Returns a clone of the contents in the transcript. - fn contents(&self) -> Vec { - self.items.clone() - } - - fn remove_ghost_snapshots(items: &mut Vec) { - items.retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. })); - } - - fn ensure_call_outputs_present(&mut self) { - // Collect synthetic outputs to insert immediately after their calls. - // Store the insertion position (index of call) alongside the item so - // we can insert in reverse order and avoid index shifting. - let mut missing_outputs_to_insert: Vec<(usize, ResponseItem)> = Vec::new(); - - for (idx, item) in self.items.iter().enumerate() { - match item { - ResponseItem::FunctionCall { call_id, .. } => { - let has_output = self.items.iter().any(|i| match i { - ResponseItem::FunctionCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - - if !has_output { - error_or_panic(format!( - "Function call output is missing for call id: {call_id}" - )); - missing_outputs_to_insert.push(( - idx, - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - )); - } - } - ResponseItem::CustomToolCall { call_id, .. } => { - let has_output = self.items.iter().any(|i| match i { - ResponseItem::CustomToolCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - - if !has_output { - error_or_panic(format!( - "Custom tool call output is missing for call id: {call_id}" - )); - missing_outputs_to_insert.push(( - idx, - ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - output: "aborted".to_string(), - }, - )); - } - } - // LocalShellCall is represented in upstream streams by a FunctionCallOutput - ResponseItem::LocalShellCall { call_id, .. } => { - if let Some(call_id) = call_id.as_ref() { - let has_output = self.items.iter().any(|i| match i { - ResponseItem::FunctionCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - - if !has_output { - error_or_panic(format!( - "Local shell call output is missing for call id: {call_id}" - )); - missing_outputs_to_insert.push(( - idx, - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - )); - } - } - } - ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Other - | ResponseItem::Message { .. } => { - // nothing to do for these variants - } - } - } - - if !missing_outputs_to_insert.is_empty() { - // Insert from the end to avoid shifting subsequent indices. - missing_outputs_to_insert.sort_by_key(|(i, _)| *i); - for (idx, item) in missing_outputs_to_insert.into_iter().rev() { - let insert_pos = idx + 1; // place immediately after the call - if insert_pos <= self.items.len() { - self.items.insert(insert_pos, item); - } else { - self.items.push(item); - } - } - } - } - - fn remove_orphan_outputs(&mut self) { - // Work on a snapshot to avoid borrowing `self.items` while mutating it. - let snapshot = self.items.clone(); - let mut orphan_output_call_ids: std::collections::HashSet = - std::collections::HashSet::new(); - - for item in &snapshot { - match item { - ResponseItem::FunctionCallOutput { call_id, .. } => { - let has_call = snapshot.iter().any(|i| match i { - ResponseItem::FunctionCall { - call_id: existing, .. - } => existing == call_id, - ResponseItem::LocalShellCall { - call_id: Some(existing), - .. - } => existing == call_id, - _ => false, - }); - - if !has_call { - error_or_panic(format!("Function call is missing for call id: {call_id}")); - orphan_output_call_ids.insert(call_id.clone()); - } - } - ResponseItem::CustomToolCallOutput { call_id, .. } => { - let has_call = snapshot.iter().any(|i| match i { - ResponseItem::CustomToolCall { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - - if !has_call { - error_or_panic(format!( - "Custom tool call is missing for call id: {call_id}" - )); - orphan_output_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Other - | ResponseItem::Message { .. } => { - // nothing to do for these variants - } - } - } - - if !orphan_output_call_ids.is_empty() { - let ids = orphan_output_call_ids; - self.items.retain(|i| match i { - ResponseItem::FunctionCallOutput { call_id, .. } - | ResponseItem::CustomToolCallOutput { call_id, .. } => !ids.contains(call_id), - _ => true, - }); - } - } - - /// Removes the corresponding paired item for the provided `item`, if any. - /// - /// Pairs: - /// - FunctionCall <-> FunctionCallOutput - /// - CustomToolCall <-> CustomToolCallOutput - /// - LocalShellCall(call_id: Some) <-> FunctionCallOutput - fn remove_corresponding_for(&mut self, item: &ResponseItem) { - match item { - ResponseItem::FunctionCall { call_id, .. } => { - self.remove_first_matching(|i| match i { - ResponseItem::FunctionCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - } - ResponseItem::CustomToolCall { call_id, .. } => { - self.remove_first_matching(|i| match i { - ResponseItem::CustomToolCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - } - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => { - self.remove_first_matching(|i| match i { - ResponseItem::FunctionCallOutput { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - } - ResponseItem::FunctionCallOutput { call_id, .. } => { - self.remove_first_matching(|i| match i { - ResponseItem::FunctionCall { - call_id: existing, .. - } => existing == call_id, - ResponseItem::LocalShellCall { - call_id: Some(existing), - .. - } => existing == call_id, - _ => false, - }); - } - ResponseItem::CustomToolCallOutput { call_id, .. } => { - self.remove_first_matching(|i| match i { - ResponseItem::CustomToolCall { - call_id: existing, .. - } => existing == call_id, - _ => false, - }); - } - _ => {} - } - } - - /// Remove the first item matching the predicate. - fn remove_first_matching(&mut self, predicate: F) - where - F: FnMut(&ResponseItem) -> bool, - { - if let Some(pos) = self.items.iter().position(predicate) { - self.items.remove(pos); - } - } - - fn process_item(item: &ResponseItem) -> ResponseItem { - match item { - ResponseItem::FunctionCallOutput { call_id, output } => { - let truncated = format_output_for_model_body(output.content.as_str()); - let truncated_items = output - .content_items - .as_ref() - .map(|items| globally_truncate_function_output_items(items)); - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: FunctionCallOutputPayload { - content: truncated, - content_items: truncated_items, - success: output.success, - }, - } - } - ResponseItem::CustomToolCallOutput { call_id, output } => { - let truncated = format_output_for_model_body(output); - ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - output: truncated, - } - } - ResponseItem::Message { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Other => item.clone(), - } - } -} - -fn globally_truncate_function_output_items( - items: &[FunctionCallOutputContentItem], -) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); - let mut remaining = MODEL_FORMAT_MAX_BYTES; - let mut omitted_text_items = 0usize; - - for it in items { - match it { - FunctionCallOutputContentItem::InputText { text } => { - if remaining == 0 { - omitted_text_items += 1; - continue; - } - - let len = text.len(); - if len <= remaining { - out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); - remaining -= len; - } else { - let slice = take_bytes_at_char_boundary(text, remaining); - if !slice.is_empty() { - out.push(FunctionCallOutputContentItem::InputText { - text: slice.to_string(), - }); - } - remaining = 0; - } - } - // todo(aibrahim): handle input images; resize - FunctionCallOutputContentItem::InputImage { image_url } => { - out.push(FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - }); - } - } - } - - if omitted_text_items > 0 { - out.push(FunctionCallOutputContentItem::InputText { - text: format!("[omitted {omitted_text_items} text items ...]"), - }); - } - - out -} - -pub(crate) fn format_output_for_model_body(content: &str) -> String { - // Head+tail truncation for the model: show the beginning and end with an elision. - // Clients still receive full streams; only this formatted summary is capped. - let total_lines = content.lines().count(); - if content.len() <= MODEL_FORMAT_MAX_BYTES && total_lines <= MODEL_FORMAT_MAX_LINES { - return content.to_string(); - } - let output = truncate_formatted_exec_output(content, total_lines); - format!("Total output lines: {total_lines}\n\n{output}") -} - -fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String { - let segments: Vec<&str> = content.split_inclusive('\n').collect(); - let head_take = MODEL_FORMAT_HEAD_LINES.min(segments.len()); - let tail_take = MODEL_FORMAT_TAIL_LINES.min(segments.len().saturating_sub(head_take)); - let omitted = segments.len().saturating_sub(head_take + tail_take); - - let head_slice_end: usize = segments - .iter() - .take(head_take) - .map(|segment| segment.len()) - .sum(); - let tail_slice_start: usize = if tail_take == 0 { - content.len() - } else { - content.len() - - segments - .iter() - .rev() - .take(tail_take) - .map(|segment| segment.len()) - .sum::() - }; - let head_slice = &content[..head_slice_end]; - let tail_slice = &content[tail_slice_start..]; - let truncated_by_bytes = content.len() > MODEL_FORMAT_MAX_BYTES; - // this is a bit wrong. We are counting metadata lines and not just shell output lines. - let marker = if omitted > 0 { - Some(format!( - "\n[... omitted {omitted} of {total_lines} lines ...]\n\n" - )) - } else if truncated_by_bytes { - Some(format!( - "\n[... output truncated to fit {MODEL_FORMAT_MAX_BYTES} bytes ...]\n\n" - )) - } else { - None - }; - - let marker_len = marker.as_ref().map_or(0, String::len); - let base_head_budget = MODEL_FORMAT_HEAD_BYTES.min(MODEL_FORMAT_MAX_BYTES); - let head_budget = base_head_budget.min(MODEL_FORMAT_MAX_BYTES.saturating_sub(marker_len)); - let head_part = take_bytes_at_char_boundary(head_slice, head_budget); - let mut result = String::with_capacity(MODEL_FORMAT_MAX_BYTES.min(content.len())); - - result.push_str(head_part); - if let Some(marker_text) = marker.as_ref() { - result.push_str(marker_text); - } - - let remaining = MODEL_FORMAT_MAX_BYTES.saturating_sub(result.len()); - if remaining == 0 { - return result; - } - - let tail_part = take_last_bytes_at_char_boundary(tail_slice, remaining); - result.push_str(tail_part); - - result -} - -/// API messages include every non-system item (user/assistant messages, reasoning, -/// tool calls, tool outputs, shell calls, and web-search calls). -fn is_api_message(message: &ResponseItem) -> bool { - match message { - ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::GhostSnapshot { .. } => false, - ResponseItem::Other => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_git::GhostCommit; - use codex_protocol::models::ContentItem; - use codex_protocol::models::FunctionCallOutputPayload; - use codex_protocol::models::LocalShellAction; - use codex_protocol::models::LocalShellExecAction; - use codex_protocol::models::LocalShellStatus; - use codex_protocol::models::ReasoningItemContent; - use codex_protocol::models::ReasoningItemReasoningSummary; - use pretty_assertions::assert_eq; - - fn assistant_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - } - } - - fn create_history_with_items(items: Vec) -> ConversationHistory { - let mut h = ConversationHistory::new(); - h.record_items(items.iter()); - h - } - - fn user_msg(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - } - } - - fn reasoning_msg(text: &str) -> ResponseItem { - ResponseItem::Reasoning { - id: String::new(), - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "summary".to_string(), - }], - content: Some(vec![ReasoningItemContent::ReasoningText { - text: text.to_string(), - }]), - encrypted_content: None, - } - } - - #[test] - fn filters_non_api_messages() { - let mut h = ConversationHistory::default(); - // System message is not API messages; Other is ignored. - let system = ResponseItem::Message { - id: None, - role: "system".to_string(), - content: vec![ContentItem::OutputText { - text: "ignored".to_string(), - }], - }; - let reasoning = reasoning_msg("thinking..."); - h.record_items([&system, &reasoning, &ResponseItem::Other]); - - // User and assistant should be retained. - let u = user_msg("hi"); - let a = assistant_msg("hello"); - h.record_items([&u, &a]); - - let items = h.contents(); - assert_eq!( - items, - vec![ - ResponseItem::Reasoning { - id: String::new(), - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "summary".to_string(), - }], - content: Some(vec![ReasoningItemContent::ReasoningText { - text: "thinking...".to_string(), - }]), - encrypted_content: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::OutputText { - text: "hi".to_string() - }] - }, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "hello".to_string() - }] - } - ] - ); - } - - #[test] - fn get_history_for_prompt_drops_ghost_commits() { - let items = vec![ResponseItem::GhostSnapshot { - ghost_commit: GhostCommit::new("ghost-1".to_string(), None, Vec::new(), Vec::new()), - }]; - let mut history = create_history_with_items(items); - let filtered = history.get_history_for_prompt(); - assert_eq!(filtered, vec![]); - } - - #[test] - fn remove_first_item_removes_matching_output_for_function_call() { - let items = vec![ - ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }, - ]; - let mut h = create_history_with_items(items); - h.remove_first_item(); - assert_eq!(h.contents(), vec![]); - } - - #[test] - fn remove_first_item_removes_matching_call_for_output() { - let items = vec![ - ResponseItem::FunctionCallOutput { - call_id: "call-2".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }, - ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - arguments: "{}".to_string(), - call_id: "call-2".to_string(), - }, - ]; - let mut h = create_history_with_items(items); - h.remove_first_item(); - assert_eq!(h.contents(), vec![]); - } - - #[test] - fn remove_first_item_handles_local_shell_pair() { - let items = vec![ - ResponseItem::LocalShellCall { - id: None, - call_id: Some("call-3".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string(), "hi".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-3".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }, - ]; - let mut h = create_history_with_items(items); - h.remove_first_item(); - assert_eq!(h.contents(), vec![]); - } - - #[test] - fn remove_first_item_handles_custom_tool_pair() { - let items = vec![ - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "tool-1".to_string(), - name: "my_tool".to_string(), - input: "{}".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "tool-1".to_string(), - output: "ok".to_string(), - }, - ]; - let mut h = create_history_with_items(items); - h.remove_first_item(); - assert_eq!(h.contents(), vec![]); - } - - #[test] - fn record_items_truncates_function_call_output_content() { - let mut history = ConversationHistory::new(); - let long_line = "a very long line to trigger truncation\n"; - let long_output = long_line.repeat(2_500); - let item = ResponseItem::FunctionCallOutput { - call_id: "call-100".to_string(), - output: FunctionCallOutputPayload { - content: long_output.clone(), - success: Some(true), - ..Default::default() - }, - }; - - history.record_items([&item]); - - assert_eq!(history.items.len(), 1); - match &history.items[0] { - ResponseItem::FunctionCallOutput { output, .. } => { - assert_ne!(output.content, long_output); - assert!( - output.content.starts_with("Total output lines:"), - "expected truncated summary, got {}", - output.content - ); - } - other => panic!("unexpected history item: {other:?}"), - } - } - - #[test] - fn record_items_truncates_custom_tool_call_output_content() { - let mut history = ConversationHistory::new(); - let line = "custom output that is very long\n"; - let long_output = line.repeat(2_500); - let item = ResponseItem::CustomToolCallOutput { - call_id: "tool-200".to_string(), - output: long_output.clone(), - }; - - history.record_items([&item]); - - assert_eq!(history.items.len(), 1); - match &history.items[0] { - ResponseItem::CustomToolCallOutput { output, .. } => { - assert_ne!(output, &long_output); - assert!( - output.starts_with("Total output lines:"), - "expected truncated summary, got {output}" - ); - } - other => panic!("unexpected history item: {other:?}"), - } - } - - // The following tests were adapted from tools::mod truncation tests to - // target the new truncation functions in conversation_history. - - use regex_lite::Regex; - - fn assert_truncated_message_matches(message: &str, line: &str, total_lines: usize) { - let pattern = truncated_message_pattern(line, total_lines); - let regex = Regex::new(&pattern).unwrap_or_else(|err| { - panic!("failed to compile regex {pattern}: {err}"); - }); - let captures = regex - .captures(message) - .unwrap_or_else(|| panic!("message failed to match pattern {pattern}: {message}")); - let body = captures - .name("body") - .expect("missing body capture") - .as_str(); - assert!( - body.len() <= MODEL_FORMAT_MAX_BYTES, - "body exceeds byte limit: {} bytes", - body.len() - ); - } - - fn truncated_message_pattern(line: &str, total_lines: usize) -> String { - let head_take = MODEL_FORMAT_HEAD_LINES.min(total_lines); - let tail_take = MODEL_FORMAT_TAIL_LINES.min(total_lines.saturating_sub(head_take)); - let omitted = total_lines.saturating_sub(head_take + tail_take); - let escaped_line = regex_lite::escape(line); - if omitted == 0 { - return format!( - r"(?s)^Total output lines: {total_lines}\n\n(?P{escaped_line}.*\n\[\.{{3}} output truncated to fit {MODEL_FORMAT_MAX_BYTES} bytes \.{{3}}]\n\n.*)$", - ); - } - format!( - r"(?s)^Total output lines: {total_lines}\n\n(?P{escaped_line}.*\n\[\.{{3}} omitted {omitted} of {total_lines} lines \.{{3}}]\n\n.*)$", - ) - } - - #[test] - fn format_exec_output_truncates_large_error() { - let line = "very long execution error line that should trigger truncation\n"; - let large_error = line.repeat(2_500); // way beyond both byte and line limits - - let truncated = format_output_for_model_body(&large_error); - - let total_lines = large_error.lines().count(); - assert_truncated_message_matches(&truncated, line, total_lines); - assert_ne!(truncated, large_error); - } - - #[test] - fn format_exec_output_marks_byte_truncation_without_omitted_lines() { - let long_line = "a".repeat(MODEL_FORMAT_MAX_BYTES + 50); - let truncated = format_output_for_model_body(&long_line); - - assert_ne!(truncated, long_line); - let marker_line = - format!("[... output truncated to fit {MODEL_FORMAT_MAX_BYTES} bytes ...]"); - assert!( - truncated.contains(&marker_line), - "missing byte truncation marker: {truncated}" - ); - assert!( - !truncated.contains("omitted"), - "line omission marker should not appear when no lines were dropped: {truncated}" - ); - } - - #[test] - fn format_exec_output_returns_original_when_within_limits() { - let content = "example output\n".repeat(10); - - assert_eq!(format_output_for_model_body(&content), content); - } - - #[test] - fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() { - let total_lines = MODEL_FORMAT_MAX_LINES + 100; - let content: String = (0..total_lines) - .map(|idx| format!("line-{idx}\n")) - .collect(); - - let truncated = format_output_for_model_body(&content); - let omitted = total_lines - MODEL_FORMAT_MAX_LINES; - let expected_marker = format!("[... omitted {omitted} of {total_lines} lines ...]"); - - assert!( - truncated.contains(&expected_marker), - "missing omitted marker: {truncated}" - ); - assert!( - truncated.contains("line-0\n"), - "expected head line to remain: {truncated}" - ); - - let last_line = format!("line-{}\n", total_lines - 1); - assert!( - truncated.contains(&last_line), - "expected tail line to remain: {truncated}" - ); - } - - #[test] - fn format_exec_output_prefers_line_marker_when_both_limits_exceeded() { - let total_lines = MODEL_FORMAT_MAX_LINES + 42; - let long_line = "x".repeat(256); - let content: String = (0..total_lines) - .map(|idx| format!("line-{idx}-{long_line}\n")) - .collect(); - - let truncated = format_output_for_model_body(&content); - - assert!( - truncated.contains("[... omitted 42 of 298 lines ...]"), - "expected omitted marker when line count exceeds limit: {truncated}" - ); - assert!( - !truncated.contains("output truncated to fit"), - "line omission marker should take precedence over byte marker: {truncated}" - ); - } - - #[test] - fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { - // Arrange: several text items, none exceeding per-item limit, but total exceeds budget. - let budget = MODEL_FORMAT_MAX_BYTES; - let t1_len = (budget / 2).saturating_sub(10); - let t2_len = (budget / 2).saturating_sub(10); - let remaining_after_t1_t2 = budget.saturating_sub(t1_len + t2_len); - let t3_len = 50; // gets truncated to remaining_after_t1_t2 - let t4_len = 5; // omitted - let t5_len = 7; // omitted - - let t1 = "a".repeat(t1_len); - let t2 = "b".repeat(t2_len); - let t3 = "c".repeat(t3_len); - let t4 = "d".repeat(t4_len); - let t5 = "e".repeat(t5_len); - - let item = ResponseItem::FunctionCallOutput { - call_id: "call-omit".to_string(), - output: FunctionCallOutputPayload { - content: "irrelevant".to_string(), - content_items: Some(vec![ - FunctionCallOutputContentItem::InputText { text: t1 }, - FunctionCallOutputContentItem::InputText { text: t2 }, - FunctionCallOutputContentItem::InputImage { - image_url: "img:mid".to_string(), - }, - FunctionCallOutputContentItem::InputText { text: t3 }, - FunctionCallOutputContentItem::InputText { text: t4 }, - FunctionCallOutputContentItem::InputText { text: t5 }, - ]), - success: Some(true), - }, - }; - - let mut history = ConversationHistory::new(); - history.record_items([&item]); - assert_eq!(history.items.len(), 1); - let json = serde_json::to_value(&history.items[0]).expect("serialize to json"); - - let output = json - .get("output") - .expect("output field") - .as_array() - .expect("array output"); - - // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. - assert_eq!(output.len(), 5); - - let first = output[0].as_object().expect("first obj"); - assert_eq!(first.get("type").unwrap(), "input_text"); - let first_text = first.get("text").unwrap().as_str().unwrap(); - assert_eq!(first_text.len(), t1_len); - - let second = output[1].as_object().expect("second obj"); - assert_eq!(second.get("type").unwrap(), "input_text"); - let second_text = second.get("text").unwrap().as_str().unwrap(); - assert_eq!(second_text.len(), t2_len); - - assert_eq!( - output[2], - serde_json::json!({"type": "input_image", "image_url": "img:mid"}) - ); - - let fourth = output[3].as_object().expect("fourth obj"); - assert_eq!(fourth.get("type").unwrap(), "input_text"); - let fourth_text = fourth.get("text").unwrap().as_str().unwrap(); - assert_eq!(fourth_text.len(), remaining_after_t1_t2); - - let summary = output[4].as_object().expect("summary obj"); - assert_eq!(summary.get("type").unwrap(), "input_text"); - let summary_text = summary.get("text").unwrap().as_str().unwrap(); - assert!(summary_text.contains("omitted 2 text items")); - } - - //TODO(aibrahim): run CI in release mode. - #[cfg(not(debug_assertions))] - #[test] - fn normalize_adds_missing_output_for_function_call() { - let items = vec![ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - arguments: "{}".to_string(), - call_id: "call-x".to_string(), - }]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!( - h.contents(), - vec![ - ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - arguments: "{}".to_string(), - call_id: "call-x".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-x".to_string(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - ] - ); - } - - #[cfg(not(debug_assertions))] - #[test] - fn normalize_adds_missing_output_for_custom_tool_call() { - let items = vec![ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "tool-x".to_string(), - name: "custom".to_string(), - input: "{}".to_string(), - }]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!( - h.contents(), - vec![ - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "tool-x".to_string(), - name: "custom".to_string(), - input: "{}".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "tool-x".to_string(), - output: "aborted".to_string(), - }, - ] - ); - } - - #[cfg(not(debug_assertions))] - #[test] - fn normalize_adds_missing_output_for_local_shell_call_with_id() { - let items = vec![ResponseItem::LocalShellCall { - id: None, - call_id: Some("shell-1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string(), "hi".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!( - h.contents(), - vec![ - ResponseItem::LocalShellCall { - id: None, - call_id: Some("shell-1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string(), "hi".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }, - ResponseItem::FunctionCallOutput { - call_id: "shell-1".to_string(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - ] - ); - } - - #[cfg(not(debug_assertions))] - #[test] - fn normalize_removes_orphan_function_call_output() { - let items = vec![ResponseItem::FunctionCallOutput { - call_id: "orphan-1".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!(h.contents(), vec![]); - } - - #[cfg(not(debug_assertions))] - #[test] - fn normalize_removes_orphan_custom_tool_call_output() { - let items = vec![ResponseItem::CustomToolCallOutput { - call_id: "orphan-2".to_string(), - output: "ok".to_string(), - }]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!(h.contents(), vec![]); - } - - #[cfg(not(debug_assertions))] - #[test] - fn normalize_mixed_inserts_and_removals() { - let items = vec![ - // Will get an inserted output - ResponseItem::FunctionCall { - id: None, - name: "f1".to_string(), - arguments: "{}".to_string(), - call_id: "c1".to_string(), - }, - // Orphan output that should be removed - ResponseItem::FunctionCallOutput { - call_id: "c2".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }, - // Will get an inserted custom tool output - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "t1".to_string(), - name: "tool".to_string(), - input: "{}".to_string(), - }, - // Local shell call also gets an inserted function call output - ResponseItem::LocalShellCall { - id: None, - call_id: Some("s1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }, - ]; - let mut h = create_history_with_items(items); - - h.normalize_history(); - - assert_eq!( - h.contents(), - vec![ - ResponseItem::FunctionCall { - id: None, - name: "f1".to_string(), - arguments: "{}".to_string(), - call_id: "c1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "c1".to_string(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "t1".to_string(), - name: "tool".to_string(), - input: "{}".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "t1".to_string(), - output: "aborted".to_string(), - }, - ResponseItem::LocalShellCall { - id: None, - call_id: Some("s1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }, - ResponseItem::FunctionCallOutput { - call_id: "s1".to_string(), - output: FunctionCallOutputPayload { - content: "aborted".to_string(), - ..Default::default() - }, - }, - ] - ); - } - - // In debug builds we panic on normalization errors instead of silently fixing them. - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_adds_missing_output_for_function_call_panics_in_debug() { - let items = vec![ResponseItem::FunctionCall { - id: None, - name: "do_it".to_string(), - arguments: "{}".to_string(), - call_id: "call-x".to_string(), - }]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { - let items = vec![ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "tool-x".to_string(), - name: "custom".to_string(), - input: "{}".to_string(), - }]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() { - let items = vec![ResponseItem::LocalShellCall { - id: None, - call_id: Some("shell-1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string(), "hi".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_removes_orphan_function_call_output_panics_in_debug() { - let items = vec![ResponseItem::FunctionCallOutput { - call_id: "orphan-1".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { - let items = vec![ResponseItem::CustomToolCallOutput { - call_id: "orphan-2".to_string(), - output: "ok".to_string(), - }]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic] - fn normalize_mixed_inserts_and_removals_panics_in_debug() { - let items = vec![ - ResponseItem::FunctionCall { - id: None, - name: "f1".to_string(), - arguments: "{}".to_string(), - call_id: "c1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "c2".to_string(), - output: FunctionCallOutputPayload { - content: "ok".to_string(), - ..Default::default() - }, - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "t1".to_string(), - name: "tool".to_string(), - input: "{}".to_string(), - }, - ResponseItem::LocalShellCall { - id: None, - call_id: Some("s1".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string()], - timeout_ms: None, - working_directory: None, - env: None, - user: None, - }), - }, - ]; - let mut h = create_history_with_items(items); - h.normalize_history(); - } -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 413cc76bf1..a045f2103e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -18,7 +18,7 @@ mod codex_delegate; mod command_safety; pub mod config; pub mod config_loader; -mod conversation_history; +mod context_manager; pub mod custom_prompts; mod environment_context; pub mod error; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index a41d2b6342..054d485b0f 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,7 +3,7 @@ use codex_protocol::models::ResponseItem; use crate::codex::SessionConfiguration; -use crate::conversation_history::ConversationHistory; +use crate::context_manager::ContextManager; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; @@ -11,7 +11,7 @@ use crate::protocol::TokenUsageInfo; /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, - pub(crate) history: ConversationHistory, + pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, } @@ -20,7 +20,7 @@ impl SessionState { pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { Self { session_configuration, - history: ConversationHistory::new(), + history: ContextManager::new(), latest_rate_limits: None, } } @@ -34,7 +34,7 @@ impl SessionState { self.history.record_items(items) } - pub(crate) fn clone_history(&self) -> ConversationHistory { + pub(crate) fn clone_history(&self) -> ContextManager { self.history.clone() } diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index f5ae4a12e1..92509c857c 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -9,7 +9,7 @@ pub mod runtimes; pub mod sandboxing; pub mod spec; -use crate::conversation_history::format_output_for_model_body; +use crate::context_manager::format_output_for_model_body; use crate::exec::ExecToolCallOutput; pub use router::ToolRouter; use serde::Serialize; From c4ebe4b07857ab5b47b7a42d7c3b018951f39040 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 5 Nov 2025 12:51:57 -0600 Subject: [PATCH 035/122] Improved token refresh handling to address "Re-connecting" behavior (#6231) Currently, when the access token expires, we attempt to use the refresh token to acquire a new access token. This works most of the time. However, there are situations where the refresh token is expired, exhausted (already used to perform a refresh), or revoked. In those cases, the current logic treats the error as transient and attempts to retry it repeatedly. This PR changes the token refresh logic to differentiate between permanent and transient errors. It also changes callers to treat the permanent errors as fatal rather than retrying them. And it provides better error messages to users so they understand how to address the problem. These error messages should also help us further understand why we're seeing examples of refresh token exhaustion. Here is the error message in the CLI. The same text appears within the extension. image I also correct the spelling of "Re-connecting", which shouldn't have a hyphen in it. Testing: I manually tested these code paths by adding temporary code to programmatically cause my refresh token to be exhausted (by calling the token refresh endpoint in a tight loop more than 50 times). I then simulated an access token expiration, which caused the token refresh logic to be invoked. I confirmed that the updated logic properly handled the error condition. Note: We earlier discussed the idea of forcefully logging out the user at the point where token refresh failed. I made several attempts to do this, and all of them resulted in a bad UX. It's important to surface this error to users in a way that explains the problem and tells them that they need to log in again. We also previously discussed deleting the auth.json file when this condition is detected. That also creates problems because it effectively changes the auth status from logged in to logged out, and this causes odd failures and inconsistent UX. I think it's therefore better not to delete auth.json in this case. If the user closes the CLI or VSCE and starts it again, we properly detect that the access token is expired and the refresh token is "dead", and we force the user to go through the login flow at that time. This should address aspects of #6191, #5679, and #5505 --- codex-rs/core/src/auth.rs | 171 +++++++++++--- codex-rs/core/src/client.rs | 16 +- codex-rs/core/src/codex.rs | 3 +- codex-rs/core/src/error.rs | 27 +++ codex-rs/core/tests/suite/auth_refresh.rs | 272 ++++++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + 6 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 codex-rs/core/tests/suite/auth_refresh.rs diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index b18cae5fa6..655d8230e4 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1,12 +1,14 @@ mod storage; use chrono::Utc; +use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; #[cfg(test)] use serial_test::serial; use std::env; use std::fmt::Debug; +use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -22,10 +24,14 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; use crate::default_client::CodexHttpClient; +use crate::error::RefreshTokenFailedError; +use crate::error::RefreshTokenFailedReason; use crate::token_data::PlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use serde_json::Value; +use thiserror::Error; #[derive(Debug, Clone)] pub struct CodexAuth { @@ -46,18 +52,54 @@ impl PartialEq for CodexAuth { // TODO(pakrym): use token exp field to check for expiration instead const TOKEN_REFRESH_INTERVAL: i64 = 8; +const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; +const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."; +const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again."; +const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = + "Your access token could not be refreshed. Please log out and sign in again."; +const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; + +#[derive(Debug, Error)] +pub enum RefreshTokenError { + #[error("{0}")] + Permanent(#[from] RefreshTokenFailedError), + #[error(transparent)] + Transient(#[from] std::io::Error), +} + +impl RefreshTokenError { + pub fn failed_reason(&self) -> Option { + match self { + Self::Permanent(error) => Some(error.reason), + Self::Transient(_) => None, + } + } + + fn other_with_message(message: impl Into) -> Self { + Self::Transient(std::io::Error::other(message.into())) + } +} + +impl From for std::io::Error { + fn from(err: RefreshTokenError) -> Self { + match err { + RefreshTokenError::Permanent(failed) => std::io::Error::other(failed), + RefreshTokenError::Transient(inner) => inner, + } + } +} + impl CodexAuth { - pub async fn refresh_token(&self) -> Result { + pub async fn refresh_token(&self) -> Result { tracing::info!("Refreshing token"); - let token_data = self - .get_current_token_data() - .ok_or(std::io::Error::other("Token data is not available."))?; + let token_data = self.get_current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other("Token data is not available.")) + })?; let token = token_data.refresh_token; - let refresh_response = try_refresh_token(token, &self.client) - .await - .map_err(std::io::Error::other)?; + let refresh_response = try_refresh_token(token, &self.client).await?; let updated = update_tokens( &self.storage, @@ -65,7 +107,8 @@ impl CodexAuth { refresh_response.access_token, refresh_response.refresh_token, ) - .await?; + .await + .map_err(RefreshTokenError::from)?; if let Ok(mut auth_lock) = self.auth_dot_json.lock() { *auth_lock = Some(updated.clone()); @@ -74,7 +117,7 @@ impl CodexAuth { let access = match updated.tokens { Some(t) => t.access_token, None => { - return Err(std::io::Error::other( + return Err(RefreshTokenError::other_with_message( "Token data is not available after refresh.", )); } @@ -99,15 +142,21 @@ impl CodexAuth { .. }) => { if last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { - let refresh_response = tokio::time::timeout( + let refresh_result = tokio::time::timeout( Duration::from_secs(60), try_refresh_token(tokens.refresh_token.clone(), &self.client), ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - })? - .map_err(std::io::Error::other)?; + .await; + let refresh_response = match refresh_result { + Ok(Ok(response)) => response, + Ok(Err(err)) => return Err(err.into()), + Err(_) => { + return Err(std::io::Error::new( + ErrorKind::TimedOut, + "timed out while refreshing OpenAI API key", + )); + } + }; let updated_auth_dot_json = update_tokens( &self.storage, @@ -425,7 +474,7 @@ async fn update_tokens( async fn try_refresh_token( refresh_token: String, client: &CodexHttpClient, -) -> std::io::Result { +) -> Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", @@ -433,30 +482,93 @@ async fn try_refresh_token( scope: "openid profile email", }; + let endpoint = refresh_token_endpoint(); + // Use shared client factory to include standard headers let response = client - .post("https://auth.openai.com/oauth/token") + .post(endpoint.as_str()) .header("Content-Type", "application/json") .json(&refresh_request) .send() .await - .map_err(std::io::Error::other)?; + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; - if response.status().is_success() { + let status = response.status(); + if status.is_success() { let refresh_response = response .json::() .await - .map_err(std::io::Error::other)?; + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; Ok(refresh_response) } else { - Err(std::io::Error::other(format!( - "Failed to refresh token: {}: {}", - response.status(), - try_parse_error_message(&response.text().await.unwrap_or_default()), - ))) + let body = response.text().await.unwrap_or_default(); + if status == StatusCode::UNAUTHORIZED { + let failed = classify_refresh_token_failure(&body); + Err(RefreshTokenError::Permanent(failed)) + } else { + let message = try_parse_error_message(&body); + Err(RefreshTokenError::Transient(std::io::Error::other( + format!("Failed to refresh token: {status}: {message}"), + ))) + } } } +fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { + let code = extract_refresh_token_error_code(body); + + let normalized_code = code.as_deref().map(str::to_ascii_lowercase); + let reason = match normalized_code.as_deref() { + Some("refresh_token_expired") => RefreshTokenFailedReason::Expired, + Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted, + Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked, + _ => RefreshTokenFailedReason::Other, + }; + + if reason == RefreshTokenFailedReason::Other { + tracing::warn!( + backend_code = normalized_code.as_deref(), + backend_body = body, + "Encountered unknown 401 response while refreshing token" + ); + } + + let message = match reason { + RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(), + RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(), + RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(), + RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), + }; + + RefreshTokenFailedError::new(reason, message) +} + +fn extract_refresh_token_error_code(body: &str) -> Option { + if body.trim().is_empty() { + return None; + } + + let Value::Object(map) = serde_json::from_str::(body).ok()? else { + return None; + }; + + if let Some(error_value) = map.get("error") { + match error_value { + Value::Object(obj) => { + if let Some(code) = obj.get("code").and_then(Value::as_str) { + return Some(code.to_string()); + } + } + Value::String(code) => { + return Some(code.to_string()); + } + _ => {} + } + } + + map.get("code").and_then(Value::as_str).map(str::to_string) +} + #[derive(Serialize)] struct RefreshRequest { client_id: &'static str, @@ -475,6 +587,11 @@ struct RefreshResponse { // Shared constant for token refresh (client id used for oauth token refresh flow) pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +fn refresh_token_endpoint() -> String { + std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) +} + use std::sync::RwLock; /// Internal cached auth state. @@ -965,7 +1082,9 @@ impl AuthManager { /// Attempt to refresh the current auth token (if any). On success, reload /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token(&self) -> std::io::Result> { + /// If the token refresh fails in a permanent (non‑transient) way, logs out + /// to clear invalid auth state. + pub async fn refresh_token(&self) -> Result, RefreshTokenError> { let auth = match self.auth() { Some(a) => a, None => return Ok(None), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9dfa3a1316..8cd1bca564 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -31,6 +31,7 @@ use tracing::warn; use crate::AuthManager; use crate::auth::CodexAuth; +use crate::auth::RefreshTokenError; use crate::chat_completions::AggregateStreamExt; use crate::chat_completions::stream_chat_completions; use crate::client_common::Prompt; @@ -389,12 +390,17 @@ impl ModelClient { && let Some(manager) = auth_manager.as_ref() && let Some(auth) = auth.as_ref() && auth.mode == AuthMode::ChatGPT + && let Err(err) = manager.refresh_token().await { - manager.refresh_token().await.map_err(|err| { - StreamAttemptError::Fatal(CodexErr::Fatal(format!( - "Failed to refresh ChatGPT credentials: {err}" - ))) - })?; + let stream_error = match err { + RefreshTokenError::Permanent(failed) => { + StreamAttemptError::Fatal(CodexErr::RefreshTokenFailed(failed)) + } + RefreshTokenError::Transient(other) => { + StreamAttemptError::RetryableTransportError(CodexErr::Io(other)) + } + }; + return Err(stream_error); } // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5bf826fc89..64cd00232e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1928,6 +1928,7 @@ async fn run_turn( return Err(CodexErr::UsageLimitReached(e)); } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), + Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. let max_retries = turn_context.client.get_provider().stream_max_retries(); @@ -1946,7 +1947,7 @@ async fn run_turn( // at a seemingly frozen screen. sess.notify_stream_error( &turn_context, - format!("Re-connecting... {retries}/{max_retries}"), + format!("Reconnecting... {retries}/{max_retries}"), ) .await; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6468327543..6ca8970e3f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -135,6 +135,9 @@ pub enum CodexErr { #[error("unsupported operation: {0}")] UnsupportedOperation(String), + #[error("{0}")] + RefreshTokenFailed(RefreshTokenFailedError), + #[error("Fatal error: {0}")] Fatal(String), @@ -201,6 +204,30 @@ impl std::fmt::Display for ResponseStreamFailed { } } +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{message}")] +pub struct RefreshTokenFailedError { + pub reason: RefreshTokenFailedReason, + pub message: String, +} + +impl RefreshTokenFailedError { + pub fn new(reason: RefreshTokenFailedReason, message: impl Into) -> Self { + Self { + reason, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshTokenFailedReason { + Expired, + Exhausted, + Revoked, + Other, +} + #[derive(Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs new file mode 100644 index 0000000000..6daaf70b5e --- /dev/null +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -0,0 +1,272 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use chrono::Duration; +use chrono::Utc; +use codex_core::CodexAuth; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::AuthDotJson; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_core::auth::RefreshTokenError; +use codex_core::auth::load_auth_dot_json; +use codex_core::auth::save_auth; +use codex_core::error::RefreshTokenFailedReason; +use codex_core::token_data::IdTokenInfo; +use codex_core::token_data::TokenData; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde::Serialize; +use serde_json::json; +use std::ffi::OsString; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const INITIAL_ACCESS_TOKEN: &str = "initial-access-token"; +const INITIAL_REFRESH_TOKEN: &str = "initial-refresh-token"; + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_succeeds_updates_storage() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token" + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let auth = ctx.auth.clone(); + + let access = auth + .refresh_token() + .await + .context("refresh should succeed")?; + assert_eq!(access, "new-access-token"); + + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should exist")?; + assert_eq!(tokens.access_token, "new-access-token"); + assert_eq!(tokens.refresh_token, "new-refresh-token"); + let refreshed_at = stored + .last_refresh + .as_ref() + .context("last_refresh should be recorded")?; + assert!( + *refreshed_at >= ctx.initial_last_refresh, + "last_refresh should advance" + ); + + let cached = auth + .get_token_data() + .await + .context("token data should be cached")?; + assert_eq!(cached.access_token, "new-access-token"); + + server.verify().await; + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_expired" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let auth = ctx.auth.clone(); + + let err = auth + .refresh_token() + .await + .err() + .context("refresh should fail")?; + assert_eq!(err.failed_reason(), Some(RefreshTokenFailedReason::Expired)); + + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should remain")?; + assert_eq!(tokens.access_token, INITIAL_ACCESS_TOKEN); + assert_eq!(tokens.refresh_token, INITIAL_REFRESH_TOKEN); + assert_eq!( + *stored + .last_refresh + .as_ref() + .context("last_refresh should remain unchanged")?, + ctx.initial_last_refresh, + ); + + server.verify().await; + Ok(()) +} + +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": "temporary-failure" + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + let auth = ctx.auth.clone(); + + let err = auth + .refresh_token() + .await + .err() + .context("refresh should fail")?; + assert!(matches!(err, RefreshTokenError::Transient(_))); + assert_eq!(err.failed_reason(), None); + + let stored = ctx.load_auth()?; + let tokens = stored.tokens.as_ref().context("tokens should remain")?; + assert_eq!(tokens.access_token, INITIAL_ACCESS_TOKEN); + assert_eq!(tokens.refresh_token, INITIAL_REFRESH_TOKEN); + assert_eq!( + *stored + .last_refresh + .as_ref() + .context("last_refresh should remain unchanged")?, + ctx.initial_last_refresh, + ); + + server.verify().await; + Ok(()) +} + +struct RefreshTokenTestContext { + codex_home: TempDir, + auth: CodexAuth, + initial_last_refresh: chrono::DateTime, + _env_guard: EnvGuard, +} + +impl RefreshTokenTestContext { + fn new(server: &MockServer) -> Result { + let codex_home = TempDir::new()?; + let initial_last_refresh = Utc::now() - Duration::days(1); + let mut id_token = IdTokenInfo::default(); + id_token.raw_jwt = minimal_jwt(); + let tokens = TokenData { + id_token, + access_token: INITIAL_ACCESS_TOKEN.to_string(), + refresh_token: INITIAL_REFRESH_TOKEN.to_string(), + account_id: Some("account-id".to_string()), + }; + let auth_dot_json = AuthDotJson { + openai_api_key: None, + tokens: Some(tokens), + last_refresh: Some(initial_last_refresh), + }; + save_auth( + codex_home.path(), + &auth_dot_json, + AuthCredentialsStoreMode::File, + )?; + + let endpoint = format!("{}/oauth/token", server.uri()); + let env_guard = EnvGuard::set(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, endpoint); + + let auth = CodexAuth::from_auth_storage(codex_home.path(), AuthCredentialsStoreMode::File)? + .context("auth should load from storage")?; + + Ok(Self { + codex_home, + auth, + initial_last_refresh, + _env_guard: env_guard, + }) + } + + fn load_auth(&self) -> Result { + load_auth_dot_json(self.codex_home.path(), AuthCredentialsStoreMode::File) + .context("load auth.json")? + .context("auth.json should exist") + } +} + +struct EnvGuard { + key: &'static str, + original: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: String) -> Self { + let original = std::env::var_os(key); + // SAFETY: these tests execute serially, so updating the process environment is safe. + unsafe { + std::env::set_var(key, &value); + } + Self { key, original } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: the guard restores the original environment value before other tests run. + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +fn minimal_jwt() -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ "sub": "user-123" }); + + fn b64(data: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) + } + + let header_bytes = match serde_json::to_vec(&header) { + Ok(bytes) => bytes, + Err(err) => panic!("serialize header: {err}"), + }; + let payload_bytes = match serde_json::to_vec(&payload) { + Ok(bytes) => bytes, + Err(err) => panic!("serialize payload: {err}"), + }; + let header_b64 = b64(&header_bytes); + let payload_b64 = b64(&payload_bytes); + let signature_b64 = b64(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e597851183..bec4f942f8 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -8,6 +8,7 @@ mod apply_patch_cli; mod apply_patch_freeform; #[cfg(not(target_os = "windows"))] mod approvals; +mod auth_refresh; mod cli_stream; mod client; mod codex_delegate; From 79aa83ee399353c27485db85b6464d36246b3fcc Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 5 Nov 2025 11:20:30 -0800 Subject: [PATCH 036/122] Update rmcp to 0.8.5 (#6261) Picks up https://github.com/modelcontextprotocol/rust-sdk/pull/511 which should fix todoist and some other MCP server oauth and may further resolve issues in https://github.com/openai/codex/issues/5045 --- codex-rs/Cargo.lock | 8 ++++---- codex-rs/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b584c85f20..f11a16586e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5010,9 +5010,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c66318b30535ccd0d3b39afaa4240d6b5a35328fb7672a28e3386c97472805" +checksum = "e5947688160b56fb6c827e3c20a72c90392a1d7e9dec74749197aa1780ac42ca" dependencies = [ "base64", "bytes", @@ -5044,9 +5044,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a19193e0d69bb1c96324b1b4daec078d4c8e76d734e53404c107437858a4d2" +checksum = "01263441d3f8635c628e33856c468b96ebbce1af2d3699ea712ca71432d4ee7a" dependencies = [ "darling 0.21.3", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3de8d47d59..36f31d2e88 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -162,7 +162,7 @@ ratatui = "0.29.0" ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" -rmcp = { version = "0.8.4", default-features = false } +rmcp = { version = "0.8.5", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" sentry = "0.34.0" From 2ab1650d4d73b789cdd5b76a0debb8d9d8f0e7d0 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 5 Nov 2025 12:28:43 -0800 Subject: [PATCH 037/122] [app-server] feat: v2 Thread APIs (#6214) Implements: ``` thread/list thread/start thread/resume thread/archive ``` along with their integration tests. These are relatively light wrappers around the existing core logic, and changes to core logic are minimal. However, an improvement made for developer ergonomics: - `thread/start` and `thread/resume` automatically attaches a conversation listener internally, so clients don't have to make a separate `AddConversationListener` call like they do today. For consistency, also updated `model/list` and `feedback/upload` (naming conventions, list API params). --- codex-rs/Cargo.lock | 2 + .../src/protocol/common.rs | 55 +- .../app-server-protocol/src/protocol/v2.rs | 218 ++++- .../app-server/src/codex_message_processor.rs | 762 ++++++++++++++---- codex-rs/app-server/tests/common/Cargo.toml | 2 + codex-rs/app-server/tests/common/lib.rs | 3 + .../app-server/tests/common/mcp_process.rs | 48 +- .../tests/common/mock_model_server.rs | 19 + codex-rs/app-server/tests/common/rollout.rs | 82 ++ .../app-server/tests/suite/list_resume.rs | 72 +- codex-rs/app-server/tests/suite/model_list.rs | 37 +- codex-rs/app-server/tests/suite/v2/mod.rs | 4 + .../tests/suite/v2/thread_archive.rs | 93 +++ .../app-server/tests/suite/v2/thread_list.rs | 205 +++++ .../tests/suite/v2/thread_resume.rs | 79 ++ .../app-server/tests/suite/v2/thread_start.rs | 81 ++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 2 +- .../core/tests/suite/user_notification.rs | 4 + 19 files changed, 1482 insertions(+), 287 deletions(-) create mode 100644 codex-rs/app-server/tests/common/rollout.rs create mode 100644 codex-rs/app-server/tests/suite/v2/thread_archive.rs create mode 100644 codex-rs/app-server/tests/suite/v2/thread_list.rs create mode 100644 codex-rs/app-server/tests/suite/v2/thread_resume.rs create mode 100644 codex-rs/app-server/tests/suite/v2/thread_start.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f11a16586e..c4121da244 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -186,9 +186,11 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-core", + "codex-protocol", "serde", "serde_json", "tokio", + "uuid", "wiremock", ] diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 75618905a6..275d294a90 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use crate::JSONRPCNotification; @@ -101,11 +102,43 @@ macro_rules! client_request_definitions { client_request_definitions! { /// NEW APIs + // Thread lifecycle + #[serde(rename = "thread/start")] + #[ts(rename = "thread/start")] + ThreadStart { + params: v2::ThreadStartParams, + response: v2::ThreadStartResponse, + }, + #[serde(rename = "thread/resume")] + #[ts(rename = "thread/resume")] + ThreadResume { + params: v2::ThreadResumeParams, + response: v2::ThreadResumeResponse, + }, + #[serde(rename = "thread/archive")] + #[ts(rename = "thread/archive")] + ThreadArchive { + params: v2::ThreadArchiveParams, + response: v2::ThreadArchiveResponse, + }, + #[serde(rename = "thread/list")] + #[ts(rename = "thread/list")] + ThreadList { + params: v2::ThreadListParams, + response: v2::ThreadListResponse, + }, + #[serde(rename = "thread/compact")] + #[ts(rename = "thread/compact")] + ThreadCompact { + params: v2::ThreadCompactParams, + response: v2::ThreadCompactResponse, + }, + #[serde(rename = "model/list")] #[ts(rename = "model/list")] - ListModels { - params: v2::ListModelsParams, - response: v2::ListModelsResponse, + ModelList { + params: v2::ModelListParams, + response: v2::ModelListResponse, }, #[serde(rename = "account/login")] @@ -131,9 +164,9 @@ client_request_definitions! { #[serde(rename = "feedback/upload")] #[ts(rename = "feedback/upload")] - UploadFeedback { - params: v2::UploadFeedbackParams, - response: v2::UploadFeedbackResponse, + FeedbackUpload { + params: v2::FeedbackUploadParams, + response: v2::FeedbackUploadResponse, }, #[serde(rename = "account/read")] @@ -292,7 +325,7 @@ macro_rules! server_request_definitions { #[allow(clippy::vec_init_then_push)] pub fn export_server_response_schemas( - out_dir: &::std::path::Path, + out_dir: &Path, ) -> ::anyhow::Result> { let mut schemas = Vec::new(); paste! { @@ -303,7 +336,7 @@ macro_rules! server_request_definitions { #[allow(clippy::vec_init_then_push)] pub fn export_server_param_schemas( - out_dir: &::std::path::Path, + out_dir: &Path, ) -> ::anyhow::Result> { let mut schemas = Vec::new(); paste! { @@ -741,16 +774,16 @@ mod tests { #[test] fn serialize_list_models() -> Result<()> { - let request = ClientRequest::ListModels { + let request = ClientRequest::ModelList { request_id: RequestId::Integer(6), - params: v2::ListModelsParams::default(), + params: v2::ModelListParams::default(), }; assert_eq!( json!({ "method": "model/list", "id": 6, "params": { - "pageSize": null, + "limit": null, "cursor": null } }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 96cb2d768e..f96bbf43c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::path::PathBuf; + use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; @@ -9,10 +12,109 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -use std::path::PathBuf; use ts_rs::TS; use uuid::Uuid; +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use kebab-case. +macro_rules! v2_enum_from_core { + ( + pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { $( $Variant ),+ } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +v2_enum_from_core!( + pub enum AskForApproval from codex_protocol::protocol::AskForApproval { + UnlessTrusted, OnFailure, OnRequest, Never + } +); + +v2_enum_from_core!( + pub enum SandboxMode from codex_protocol::config_types::SandboxMode { + ReadOnly, WorkspaceWrite, DangerFullAccess + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + ReadOnly, + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -82,11 +184,11 @@ pub struct GetAccountResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ListModelsParams { - /// Optional page size; defaults to a reasonable server-side value. - pub page_size: Option, +pub struct ModelListParams { /// Opaque pagination cursor returned by a previous call. pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -114,17 +216,17 @@ pub struct ReasoningEffortOption { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ListModelsResponse { - pub items: Vec, +pub struct ModelListResponse { + pub data: Vec, /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. + /// If None, there are no more items to return. pub next_cursor: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct UploadFeedbackParams { +pub struct FeedbackUploadParams { pub classification: String, pub reason: Option, pub conversation_id: Option, @@ -134,10 +236,101 @@ pub struct UploadFeedbackParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct UploadFeedbackResponse { +pub struct FeedbackUploadResponse { pub thread_id: String, } +// === Threads, Turns, and Items === +// Thread APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + pub model: Option, + pub model_provider: Option, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox: Option, + pub config: Option>, + pub base_instructions: Option, + pub developer_instructions: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + pub model_providers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -145,13 +338,6 @@ pub struct AccountUpdatedNotification { pub auth_method: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Thread { - pub id: String, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3609b04403..1d7c0a4871 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -20,6 +20,8 @@ use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; use codex_app_server_protocol::ExecOneOffCommandParams; use codex_app_server_protocol::ExecOneOffCommandResponse; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; @@ -34,12 +36,12 @@ use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; -use codex_app_server_protocol::ListModelsParams; -use codex_app_server_protocol::ListModelsResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::RemoveConversationListenerParams; @@ -56,8 +58,16 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; -use codex_app_server_protocol::UploadFeedbackParams; -use codex_app_server_protocol::UploadFeedbackResponse; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserSavedConfig; use codex_backend_client::Client as BackendClient; @@ -83,6 +93,7 @@ use codex_core::exec_env::create_env; use codex_core::find_conversation_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::git_info::git_diff_to_remote; +use codex_core::parse_cursor; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; @@ -98,7 +109,7 @@ use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; @@ -176,6 +187,26 @@ impl CodexMessageProcessor { ClientRequest::Initialize { .. } => { panic!("Initialize should be handled in MessageProcessor"); } + // === v2 Thread APIs === + ClientRequest::ThreadStart { request_id, params } => { + self.thread_start(request_id, params).await; + } + ClientRequest::ThreadResume { request_id, params } => { + self.thread_resume(request_id, params).await; + } + ClientRequest::ThreadArchive { request_id, params } => { + self.thread_archive(request_id, params).await; + } + ClientRequest::ThreadList { request_id, params } => { + self.thread_list(request_id, params).await; + } + ClientRequest::ThreadCompact { + request_id, + params: _, + } => { + self.send_unimplemented_error(request_id, "thread/compact") + .await; + } ClientRequest::NewConversation { request_id, params } => { // Do not tokio::spawn() to process new_conversation() // asynchronously because we need to ensure the conversation is @@ -188,7 +219,7 @@ impl CodexMessageProcessor { ClientRequest::ListConversations { request_id, params } => { self.handle_list_conversations(request_id, params).await; } - ClientRequest::ListModels { request_id, params } => { + ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } ClientRequest::LoginAccount { @@ -289,7 +320,7 @@ impl CodexMessageProcessor { } => { self.get_account_rate_limits(request_id).await; } - ClientRequest::UploadFeedback { request_id, params } => { + ClientRequest::FeedbackUpload { request_id, params } => { self.upload_feedback(request_id, params).await; } } @@ -637,7 +668,7 @@ impl CodexMessageProcessor { } } - async fn fetch_account_rate_limits(&self) -> Result { + async fn fetch_account_rate_limits(&self) -> Result { let Some(auth) = self.auth_manager.auth() else { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -816,19 +847,47 @@ impl CodexMessageProcessor { } async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { - let config = - match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()).await { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: cli_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = params; + + let overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + + let config = match derive_config_from_params(overrides, cli_overrides).await { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; match self.conversation_manager.new_conversation(config).await { Ok(conversation_id) => { @@ -856,6 +915,276 @@ impl CodexMessageProcessor { } } + async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { + // Build ConfigOverrides directly from ThreadStartParams for config derivation. + let cli_overrides = params.config; + let overrides = ConfigOverrides { + model: params.model, + cwd: params.cwd.map(PathBuf::from), + approval_policy: params + .approval_policy + .map(codex_app_server_protocol::AskForApproval::to_core), + sandbox_mode: params + .sandbox + .map(codex_app_server_protocol::SandboxMode::to_core), + model_provider: params.model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions: params.base_instructions, + developer_instructions: params.developer_instructions, + ..Default::default() + }; + + let config = match derive_config_from_params(overrides, cli_overrides).await { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self.conversation_manager.new_conversation(config).await { + Ok(new_conv) => { + let thread = Thread { + id: new_conv.conversation_id.to_string(), + }; + + let response = ThreadStartResponse { + thread: thread.clone(), + }; + + // Auto-attach a conversation listener when starting a thread. + // Use the same behavior as the v1 API with experimental_raw_events=false. + if let Err(err) = self + .attach_conversation_listener(new_conv.conversation_id, false) + .await + { + tracing::warn!( + "failed to attach listener for conversation {}: {}", + new_conv.conversation_id, + err.message + ); + } + + self.outgoing.send_response(request_id, response).await; + + let notif = ThreadStartedNotification { thread }; + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("error creating thread: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn thread_archive(&mut self, request_id: RequestId, params: ThreadArchiveParams) { + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let rollout_path = match find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no rollout found for conversation id {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to locate conversation id {conversation_id}: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .archive_conversation_common(conversation_id, &rollout_path) + .await + { + Ok(()) => { + let response = ThreadArchiveResponse {}; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn thread_list(&self, request_id: RequestId, params: ThreadListParams) { + let ThreadListParams { + cursor, + limit, + model_providers, + } = params; + + let page_size = limit.unwrap_or(25).max(1) as usize; + + let (summaries, next_cursor) = match self + .list_conversations_common(page_size, cursor, model_providers) + .await + { + Ok(r) => r, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let data = summaries + .into_iter() + .map(|s| Thread { + id: s.conversation_id.to_string(), + }) + .collect(); + + let response = ThreadListResponse { data, next_cursor }; + self.outgoing.send_response(request_id, response).await; + } + + async fn thread_resume(&mut self, request_id: RequestId, params: ThreadResumeParams) { + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let path = match find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no rollout found for conversation id {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to locate conversation id {conversation_id}: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let fallback_provider = self.config.model_provider_id.as_str(); + let summary = match read_summary_from_rollout(&path, fallback_provider).await { + Ok(s) => s, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to load rollout `{}`: {err}", path.display()), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let initial_history = match RolloutRecorder::get_rollout_history(&summary.path).await { + Ok(initial_history) => initial_history, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "failed to load rollout `{}` for conversation {conversation_id}: {err}", + summary.path.display() + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .conversation_manager + .resume_conversation_with_history( + self.config.as_ref().clone(), + initial_history, + self.auth_manager.clone(), + ) + .await + { + Ok(_) => { + // Auto-attach a conversation listener when resuming a thread. + if let Err(err) = self + .attach_conversation_listener(conversation_id, false) + .await + { + tracing::warn!( + "failed to attach listener for conversation {}: {}", + conversation_id, + err.message + ); + } + + let response = ThreadResumeResponse { + thread: Thread { + id: conversation_id.to_string(), + }, + }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("error resuming thread: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn get_conversation_summary( &self, request_id: RequestId, @@ -925,11 +1254,7 @@ impl CodexMessageProcessor { model_providers: model_provider, } = params; let page_size = page_size.unwrap_or(25); - // Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string) - let cursor_obj: Option = match cursor { - Some(s) => serde_json::from_str::(&format!("\"{s}\"")).ok(), - None => None, - }; + let cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); let cursor_ref = cursor_obj.as_ref(); let model_provider_filter = match model_provider { Some(providers) => { @@ -973,33 +1298,86 @@ impl CodexMessageProcessor { .collect(); // Encode next_cursor as a plain string - let next_cursor = match page.next_cursor { - Some(c) => match serde_json::to_value(&c) { - Ok(serde_json::Value::String(s)) => Some(s), - _ => None, - }, - None => None, - }; + let next_cursor = page + .next_cursor + .and_then(|cursor| serde_json::to_value(&cursor).ok()) + .and_then(|value| value.as_str().map(str::to_owned)); let response = ListConversationsResponse { items, next_cursor }; self.outgoing.send_response(request_id, response).await; } - async fn list_models(&self, request_id: RequestId, params: ListModelsParams) { - let ListModelsParams { page_size, cursor } = params; + async fn list_conversations_common( + &self, + page_size: usize, + cursor: Option, + model_providers: Option>, + ) -> Result<(Vec, Option), JSONRPCErrorError> { + let cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); + let cursor_ref = cursor_obj.as_ref(); + + let model_provider_filter = match model_providers { + Some(providers) => { + if providers.is_empty() { + None + } else { + Some(providers) + } + } + None => Some(vec![self.config.model_provider_id.clone()]), + }; + let fallback_provider = self.config.model_provider_id.clone(); + + let page = match RolloutRecorder::list_conversations( + &self.config.codex_home, + page_size, + cursor_ref, + INTERACTIVE_SESSION_SOURCES, + model_provider_filter.as_deref(), + fallback_provider.as_str(), + ) + .await + { + Ok(p) => p, + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to list conversations: {err}"), + data: None, + }); + } + }; + + let items = page + .items + .into_iter() + .filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider)) + .collect::>(); + + let next_cursor = page + .next_cursor + .and_then(|cursor| serde_json::to_value(&cursor).ok()) + .and_then(|value| value.as_str().map(str::to_owned)); + + Ok((items, next_cursor)) + } + + async fn list_models(&self, request_id: RequestId, params: ModelListParams) { + let ModelListParams { limit, cursor } = params; let models = supported_models(); let total = models.len(); if total == 0 { - let response = ListModelsResponse { - items: Vec::new(), + let response = ModelListResponse { + data: Vec::new(), next_cursor: None, }; self.outgoing.send_response(request_id, response).await; return; } - let effective_page_size = page_size.unwrap_or(total).max(1).min(total); + let effective_page_size = limit.unwrap_or(total as u32).max(1) as usize; + let effective_page_size = effective_page_size.min(total); let start = match cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, @@ -1033,7 +1411,10 @@ impl CodexMessageProcessor { } else { None }; - let response = ListModelsResponse { items, next_cursor }; + let response = ModelListResponse { + data: items, + next_cursor, + }; self.outgoing.send_response(request_id, response).await; } @@ -1052,7 +1433,36 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let config = match overrides { Some(overrides) => { - derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone()).await + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox, + config: cli_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = overrides; + + let overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode: sandbox, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + + derive_config_from_params(overrides, cli_overrides).await } None => Ok(self.config.as_ref().clone()), }; @@ -1196,82 +1606,103 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } - async fn archive_conversation(&self, request_id: RequestId, params: ArchiveConversationParams) { + async fn archive_conversation( + &mut self, + request_id: RequestId, + params: ArchiveConversationParams, + ) { let ArchiveConversationParams { conversation_id, rollout_path, } = params; - // Verify that the rollout path is in the sessions directory or else - // a malicious client could specify an arbitrary path. + match self + .archive_conversation_common(conversation_id, &rollout_path) + .await + { + Ok(()) => { + tracing::info!("thread/archive succeeded for {conversation_id}"); + let response = ArchiveConversationResponse {}; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + tracing::warn!( + "thread/archive failed for {conversation_id}: {}", + err.message + ); + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn archive_conversation_common( + &mut self, + conversation_id: ConversationId, + rollout_path: &Path, + ) -> Result<(), JSONRPCErrorError> { + // Verify rollout_path is under sessions dir. let rollout_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR); + let canonical_sessions_dir = match tokio::fs::canonicalize(&rollout_folder).await { Ok(path) => path, Err(err) => { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!( "failed to archive conversation: unable to resolve sessions directory: {err}" ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } }; - let canonical_rollout_path = tokio::fs::canonicalize(&rollout_path).await; + let canonical_rollout_path = tokio::fs::canonicalize(rollout_path).await; let canonical_rollout_path = if let Ok(path) = canonical_rollout_path && path.starts_with(&canonical_sessions_dir) { path } else { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` must be in sessions directory", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); }; + // Verify file name matches conversation id. let required_suffix = format!("{conversation_id}.jsonl"); let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` missing file name", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); }; - if !file_name .to_string_lossy() .ends_with(required_suffix.as_str()) { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` does not match conversation id {conversation_id}", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - let removed_conversation = self + // If the conversation is active, request shutdown and wait briefly. + if let Some(conversation) = self .conversation_manager .remove_conversation(&conversation_id) - .await; - if let Some(conversation) = removed_conversation { + .await + { info!("conversation {conversation_id} was active; shutting down"); let conversation_clone = conversation.clone(); let notify = Arc::new(tokio::sync::Notify::new()); @@ -1280,20 +1711,24 @@ impl CodexMessageProcessor { // Establish the listener for ShutdownComplete before submitting // Shutdown so it is not missed. let is_shutdown = tokio::spawn(async move { + // Create the notified future outside the loop to avoid losing notifications. + let notified = notify_clone.notified(); + tokio::pin!(notified); loop { select! { - _ = notify_clone.notified() => { - break; - } + _ = &mut notified => { break; } event = conversation_clone.next_event() => { - if let Ok(event) = event && matches!(event.msg, EventMsg::ShutdownComplete) { - break; + match event { + Ok(event) => { + if matches!(event.msg, EventMsg::ShutdownComplete) { break; } + } + // Break on errors to avoid tight loops when the agent loop has exited. + Err(_) => { break; } } } } } }); - // Request shutdown. match conversation.submit(Op::Shutdown).await { Ok(_) => { @@ -1304,20 +1739,21 @@ impl CodexMessageProcessor { } _ = tokio::time::sleep(Duration::from_secs(10)) => { warn!("conversation {conversation_id} shutdown timed out; proceeding with archive"); - notify.notify_one(); + // Wake any waiter; use notify_waiters to avoid missing the signal. + notify.notify_waiters(); + // Perhaps we lost a shutdown race, so let's continue to + // clean up the .jsonl file. } } } Err(err) => { error!("failed to submit Shutdown to conversation {conversation_id}: {err}"); - notify.notify_one(); - // Perhaps we lost a shutdown race, so let's continue to - // clean up the .jsonl file. + notify.notify_waiters(); } } } - // Move the .jsonl file to the archived sessions subdir. + // Move the rollout file to archived. let result: std::io::Result<()> = async { let archive_folder = self .config @@ -1329,20 +1765,11 @@ impl CodexMessageProcessor { } .await; - match result { - Ok(()) => { - let response = ArchiveConversationResponse {}; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to archive conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + result.map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to archive conversation: {err}"), + data: None, + }) } async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) { @@ -1478,24 +1905,70 @@ impl CodexMessageProcessor { conversation_id, experimental_raw_events, } = params; - let Ok(conversation) = self + + match self + .attach_conversation_listener(conversation_id, experimental_raw_events) + .await + { + Ok(subscription_id) => { + let response = AddConversationSubscriptionResponse { subscription_id }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn remove_conversation_listener( + &mut self, + request_id: RequestId, + params: RemoveConversationListenerParams, + ) { + let RemoveConversationListenerParams { subscription_id } = params; + match self.conversation_listeners.remove(&subscription_id) { + Some(sender) => { + // Signal the spawned task to exit and acknowledge. + let _ = sender.send(()); + let response = RemoveConversationSubscriptionResponse {}; + self.outgoing.send_response(request_id, response).await; + } + None => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("subscription not found: {subscription_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn attach_conversation_listener( + &mut self, + conversation_id: ConversationId, + experimental_raw_events: bool, + ) -> Result { + let conversation = match self .conversation_manager .get_conversation(conversation_id) .await - else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + { + Ok(conv) => conv, + Err(_) => { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + }); + } }; let subscription_id = Uuid::new_v4(); let (cancel_tx, mut cancel_rx) = oneshot::channel(); self.conversation_listeners .insert(subscription_id, cancel_tx); + let outgoing_for_task = self.outgoing.clone(); let pending_interrupts = self.pending_interrupts.clone(); tokio::spawn(async move { @@ -1535,45 +2008,32 @@ impl CodexMessageProcessor { continue; } }; - params.insert("conversationId".to_string(), conversation_id.to_string().into()); + params.insert( + "conversationId".to_string(), + conversation_id.to_string().into(), + ); - outgoing_for_task.send_notification(OutgoingNotification { - method, - params: Some(params.into()), - }) + outgoing_for_task + .send_notification(OutgoingNotification { + method, + params: Some(params.into()), + }) + .await; + + apply_bespoke_event_handling( + event.clone(), + conversation_id, + conversation.clone(), + outgoing_for_task.clone(), + pending_interrupts.clone(), + ) .await; - - apply_bespoke_event_handling(event.clone(), conversation_id, conversation.clone(), outgoing_for_task.clone(), pending_interrupts.clone()).await; } } } }); - let response = AddConversationSubscriptionResponse { subscription_id }; - self.outgoing.send_response(request_id, response).await; - } - async fn remove_conversation_listener( - &mut self, - request_id: RequestId, - params: RemoveConversationListenerParams, - ) { - let RemoveConversationListenerParams { subscription_id } = params; - match self.conversation_listeners.remove(&subscription_id) { - Some(sender) => { - // Signal the spawned task to exit and acknowledge. - let _ = sender.send(()); - let response = RemoveConversationSubscriptionResponse {}; - self.outgoing.send_response(request_id, response).await; - } - None => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("subscription not found: {subscription_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + Ok(subscription_id) } async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) { @@ -1637,8 +2097,8 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn upload_feedback(&self, request_id: RequestId, params: UploadFeedbackParams) { - let UploadFeedbackParams { + async fn upload_feedback(&self, request_id: RequestId, params: FeedbackUploadParams) { + let FeedbackUploadParams { classification, reason, conversation_id, @@ -1683,7 +2143,7 @@ impl CodexMessageProcessor { match upload_result { Ok(()) => { - let response = UploadFeedbackResponse { thread_id }; + let response = FeedbackUploadResponse { thread_id }; self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -1797,41 +2257,9 @@ async fn apply_bespoke_event_handling( } async fn derive_config_from_params( - params: NewConversationParams, - codex_linux_sandbox_exe: Option, + overrides: ConfigOverrides, + cli_overrides: Option>, ) -> std::io::Result { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: cli_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = params; - let overrides = ConfigOverrides { - model, - review_model: None, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - show_raw_agent_reasoning: None, - tools_web_search_request: None, - experimental_sandbox_command_assessment: None, - additional_writable_roots: Vec::new(), - }; - let cli_overrides = cli_overrides .unwrap_or_default() .into_iter() diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index ece8174dd9..6240f755ee 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -13,6 +13,7 @@ base64 = { workspace = true } chrono = { workspace = true } codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } +codex-protocol = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ @@ -21,4 +22,5 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", ] } +uuid = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 71b27190c2..dc3d24ccae 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -2,6 +2,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; mod responses; +mod rollout; pub use auth_fixtures::ChatGptAuthFixture; pub use auth_fixtures::ChatGptIdTokenClaims; @@ -10,9 +11,11 @@ pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; +pub use mock_model_server::create_mock_chat_completions_server_unchecked; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; +pub use rollout::create_fake_rollout; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3c1fbf777c..254f121a88 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -17,12 +17,13 @@ use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::ListConversationsParams; -use codex_app_server_protocol::ListModelsParams; use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::ResumeConversationParams; @@ -30,7 +31,10 @@ use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SetDefaultModelParams; -use codex_app_server_protocol::UploadFeedbackParams; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -246,7 +250,7 @@ impl McpProcess { /// Send a `feedback/upload` JSON-RPC request. pub async fn send_upload_feedback_request( &mut self, - params: UploadFeedbackParams, + params: FeedbackUploadParams, ) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); self.send_request("feedback/upload", params).await @@ -275,10 +279,46 @@ impl McpProcess { self.send_request("listConversations", params).await } + /// Send a `thread/start` JSON-RPC request. + pub async fn send_thread_start_request( + &mut self, + params: ThreadStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/start", params).await + } + + /// Send a `thread/resume` JSON-RPC request. + pub async fn send_thread_resume_request( + &mut self, + params: ThreadResumeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/resume", params).await + } + + /// Send a `thread/archive` JSON-RPC request. + pub async fn send_thread_archive_request( + &mut self, + params: ThreadArchiveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/archive", params).await + } + + /// Send a `thread/list` JSON-RPC request. + pub async fn send_thread_list_request( + &mut self, + params: ThreadListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/list", params).await + } + /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, - params: ListModelsParams, + params: ModelListParams, ) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); self.send_request("model/list", params).await diff --git a/codex-rs/app-server/tests/common/mock_model_server.rs b/codex-rs/app-server/tests/common/mock_model_server.rs index be7f3eb5b3..08765338a9 100644 --- a/codex-rs/app-server/tests/common/mock_model_server.rs +++ b/codex-rs/app-server/tests/common/mock_model_server.rs @@ -29,6 +29,25 @@ pub async fn create_mock_chat_completions_server(responses: Vec) -> Mock server } +/// Same as `create_mock_chat_completions_server` but does not enforce an +/// expectation on the number of calls. +pub async fn create_mock_chat_completions_server_unchecked(responses: Vec) -> MockServer { + let server = MockServer::start().await; + + let seq_responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(seq_responder) + .mount(&server) + .await; + + server +} + struct SeqResponder { num_calls: AtomicUsize, responses: Vec, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs new file mode 100644 index 0000000000..c8197a046d --- /dev/null +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use codex_protocol::ConversationId; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionSource; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use uuid::Uuid; + +/// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. +/// +/// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. +/// - `meta_rfc3339` is the envelope timestamp used in JSON lines. +/// - `preview` is the user message preview text. +/// - `model_provider` optionally sets the provider in the session meta payload. +/// +/// Returns the generated conversation/session UUID as a string. +pub fn create_fake_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, +) -> Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let conversation_id = ConversationId::from_string(&uuid_str)?; + + // sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss) + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let dir = codex_home.join("sessions").join(year).join(month).join(day); + fs::create_dir_all(&dir)?; + + let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + + // Build JSONL lines + let payload = serde_json::to_value(SessionMeta { + id: conversation_id, + timestamp: meta_rfc3339.to_string(), + cwd: PathBuf::from("/"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + instructions: None, + source: SessionSource::Cli, + model_provider: model_provider.map(str::to_string), + })?; + + let lines = [ + json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": payload + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"response_item", + "payload": { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text": preview}] + } + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": preview, + "kind": "plain" + } + }) + .to_string(), + ]; + + fs::write(file_path, lines.join("\n") + "\n")?; + Ok(uuid_str) +} diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 76b92b3edc..30be93a2e6 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -1,5 +1,6 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -15,12 +16,8 @@ use codex_core::protocol::EventMsg; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; -use serde_json::json; -use std::fs; -use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -357,70 +354,3 @@ async fn test_list_and_resume_conversations() -> Result<()> { Ok(()) } - -fn create_fake_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: Option<&str>, -) -> Result<()> { - let uuid = Uuid::new_v4(); - // sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss) - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - fs::create_dir_all(&dir)?; - - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); - let mut lines = Vec::new(); - // Meta line with timestamp (flattened meta in payload for new schema) - let mut payload = json!({ - "id": uuid, - "timestamp": meta_rfc3339, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "instructions": null, - }); - if let Some(provider) = model_provider { - payload["model_provider"] = json!(provider); - } - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": payload - }) - .to_string(), - ); - // Minimal user message entry as a persisted response item (with envelope timestamp) - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"response_item", - "payload": { - "type":"message", - "role":"user", - "content":[{"type":"input_text","text": preview}] - } - }) - .to_string(), - ); - // Add a matching user message event line to satisfy filters - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"event_msg", - "payload": { - "type":"user_message", - "message": preview, - "kind": "plain" - } - }) - .to_string(), - ); - fs::write(file_path, lines.join("\n") + "\n")?; - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/model_list.rs index 3472e0d3cb..b652121f58 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/model_list.rs @@ -6,9 +6,9 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ListModelsParams; -use codex_app_server_protocol::ListModelsResponse; use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; use codex_protocol::config_types::ReasoningEffort; @@ -27,8 +27,8 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(100), + .send_list_models_request(ModelListParams { + limit: Some(100), cursor: None, }) .await?; @@ -39,7 +39,10 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { ) .await??; - let ListModelsResponse { items, next_cursor } = to_response::(response)?; + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; let expected_models = vec![ Model { @@ -111,8 +114,8 @@ async fn list_models_pagination_works() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_request = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(1), + .send_list_models_request(ModelListParams { + limit: Some(1), cursor: None, }) .await?; @@ -123,18 +126,18 @@ async fn list_models_pagination_works() -> Result<()> { ) .await??; - let ListModelsResponse { - items: first_items, + let ModelListResponse { + data: first_items, next_cursor: first_cursor, - } = to_response::(first_response)?; + } = to_response::(first_response)?; assert_eq!(first_items.len(), 1); assert_eq!(first_items[0].id, "gpt-5-codex"); let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?; let second_request = mcp - .send_list_models_request(ListModelsParams { - page_size: Some(1), + .send_list_models_request(ModelListParams { + limit: Some(1), cursor: Some(next_cursor.clone()), }) .await?; @@ -145,10 +148,10 @@ async fn list_models_pagination_works() -> Result<()> { ) .await??; - let ListModelsResponse { - items: second_items, + let ModelListResponse { + data: second_items, next_cursor: second_cursor, - } = to_response::(second_response)?; + } = to_response::(second_response)?; assert_eq!(second_items.len(), 1); assert_eq!(second_items[0].id, "gpt-5"); @@ -164,8 +167,8 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_list_models_request(ListModelsParams { - page_size: None, + .send_list_models_request(ModelListParams { + limit: None, cursor: Some("invalid".to_string()), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bd7a1467cd..ccaa9edae7 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,2 +1,6 @@ // v2 test suite modules mod account; +mod thread_archive; +mod thread_list; +mod thread_resume; +mod thread_start; diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs new file mode 100644 index 0000000000..083f3da901 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::find_conversation_path_by_id_str; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(start_resp)?; + assert!(!thread.id.is_empty()); + + // Locate the rollout path recorded for this thread id. + let rollout_path = find_conversation_path_by_id_str(codex_home.path(), &thread.id) + .await? + .expect("expected rollout path for thread id to exist"); + assert!( + rollout_path.exists(), + "expected {} to exist", + rollout_path.display() + ); + + // Archive the thread. + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + // Verify file moved. + let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + // The archived file keeps the original filename (rollout-...-.jsonl). + let archived_rollout_path = + archived_directory.join(rollout_path.file_name().expect("rollout file name")); + assert!( + !rollout_path.exists(), + "expected rollout path {} to be moved", + rollout_path.display() + ); + assert!( + archived_rollout_path.exists(), + "expected archived rollout path {} to exist", + archived_rollout_path.display() + ); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, config_contents()) +} + +fn config_contents() -> &'static str { + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +"# +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs new file mode 100644 index 0000000000..43e243b0ff --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -0,0 +1,205 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_list_basic_empty() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // List threads in an empty CODEX_HOME; should return an empty page with nextCursor: null. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, next_cursor } = to_response::(list_resp)?; + assert!(data.is_empty()); + assert_eq!(next_cursor, None); + + Ok(()) +} + +// Minimal config.toml for listing. +fn create_minimal_config(codex_home: &std::path::Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +"#, + ) +} + +#[tokio::test] +async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create three rollouts so we can paginate with limit=2. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + )?; + let _b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + )?; + let _c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Page 1: limit 2 → expect next_cursor Some. + let page1_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(2), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let page1_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(page1_id)), + ) + .await??; + let ThreadListResponse { + data: data1, + next_cursor: cursor1, + } = to_response::(page1_resp)?; + assert_eq!(data1.len(), 2); + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + // Page 2: with cursor → expect next_cursor None when no more results. + let page2_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: Some(cursor1), + limit: Some(2), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let page2_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(page2_id)), + ) + .await??; + let ThreadListResponse { + data: data2, + next_cursor: cursor2, + } = to_response::(page2_resp)?; + assert!(data2.len() <= 2); + assert_eq!(cursor2, None, "expected nextCursor to be null on last page"); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_respects_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create rollouts under two providers. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "X", + Some("mock_provider"), + )?; // mock_provider + // one with a different provider + let uuid = Uuid::new_v4(); + let dir = codex_home + .path() + .join("sessions") + .join("2025") + .join("01") + .join("02"); + std::fs::create_dir_all(&dir)?; + let file_path = dir.join(format!("rollout-2025-01-02T11-00-00-{uuid}.jsonl")); + let lines = [ + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": "2025-01-02T11:00:00Z", + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "instructions": null, + "source": "vscode", + "model_provider": "other_provider" + } + }) + .to_string(), + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type":"response_item", + "payload": {"type":"message","role":"user","content":[{"type":"input_text","text":"X"}]} + }) + .to_string(), + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type":"event_msg", + "payload": {"type":"user_message","message":"X","kind":"plain"} + }) + .to_string(), + ]; + std::fs::write(file_path, lines.join("\n") + "\n")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Filter to only other_provider; expect 1 item, nextCursor None. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: Some(vec!["other_provider".to_string()]), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, next_cursor } = to_response::(resp)?; + assert_eq!(data.len(), 1); + assert_eq!(next_cursor, None); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs new file mode 100644 index 0000000000..039f542fd7 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_resume_returns_existing_thread() -> Result<()> { + let server = create_mock_chat_completions_server(vec![]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(start_resp)?; + + // Resume it via v2 API. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread: resumed } = + to_response::(resume_resp)?; + assert_eq!(resumed.id, thread.id); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs new file mode 100644 index 0000000000..8b4cd5571d --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -0,0 +1,81 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_start_creates_thread_and_emits_started() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + let server = create_mock_chat_completions_server(vec![]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + // Start server and initialize. + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread with an explicit model override. + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5".to_string()), + ..Default::default() + }) + .await?; + + // Expect a proper JSON-RPC response with a thread id. + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(resp)?; + assert!(!thread.id.is_empty(), "thread id should not be empty"); + + // A corresponding thread/started notification should arrive. + let notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!(started.thread.id, thread.id); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a045f2103e..93a317700f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -75,6 +75,7 @@ pub use rollout::find_conversation_path_by_id_str; pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; +pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; mod function_tool; mod state; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index d1bc82e092..781e3fe372 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -273,7 +273,7 @@ async fn traverse_directories_for_paths( /// Pagination cursor token format: "|" where `file_ts` matches the /// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames. /// The cursor orders files by timestamp desc, then UUID desc. -fn parse_cursor(token: &str) -> Option { +pub fn parse_cursor(token: &str) -> Option { let (file_ts, uuid_str) = token.split_once('|')?; let Ok(uuid) = Uuid::parse_str(uuid_str) else { diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 6fc31370c3..74b59736f4 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -24,6 +24,10 @@ use responses::start_mock_server; use std::time::Duration; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "flaky on ubuntu-24.04-arm - aarch64-unknown-linux-gnu"] +// The notify script gets far enough to create (and therefore surface) the file, +// but hasn’t flushed the JSON yet. Reading an empty file produces EOF while parsing +// a value at line 1 column 0. May be caused by a slow runner. async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From d7953aed747eacef07ee17a3790b3cc75e8c836c Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 5 Nov 2025 15:12:25 -0600 Subject: [PATCH 038/122] Fixes intermittent test failures in CI (#6282) I'm seeing two tests fail intermittently in CI. This PR attempts to address (or at least mitigate) the flakiness. * summarize_context_three_requests_and_instructions - The test snapshots server.received_requests() immediately after observing TaskComplete. Because the OpenAI /v1/responses call is streamed, the HTTP request can still be draining when that event fires, so wiremock occasionally reports only two captured requests. Fix is to wait for async activity to complete. * archive_conversation_moves_rollout_into_archived_directory - times out on a slow CI run. Mitigation is to increase timeout value from 10s to 20s. --- .../tests/suite/archive_conversation.rs | 2 +- codex-rs/core/tests/suite/compact.rs | 21 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server/tests/suite/archive_conversation.rs b/codex-rs/app-server/tests/suite/archive_conversation.rs index 2d8f746a69..b6e85936fb 100644 --- a/codex-rs/app-server/tests/suite/archive_conversation.rs +++ b/codex-rs/app-server/tests/suite/archive_conversation.rs @@ -12,7 +12,7 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<()> { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b634cf0e2f..72fe21841b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -108,19 +108,19 @@ async fn summarize_context_three_requests_and_instructions() { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("\"text\":\"hello world\"") && !body.contains(COMPACT_PROMPT_MARKER) }; - mount_sse_once_match(&server, first_matcher, sse1).await; + let first_request_mock = mount_sse_once_match(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(COMPACT_PROMPT_MARKER) }; - mount_sse_once_match(&server, second_matcher, sse2).await; + let second_request_mock = mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - mount_sse_once_match(&server, third_matcher, sse3).await; + let third_request_mock = mount_sse_once_match(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { @@ -172,16 +172,13 @@ async fn summarize_context_three_requests_and_instructions() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Inspect the three captured requests. - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 3, "expected exactly three requests"); + let req1 = first_request_mock.single_request(); + let req2 = second_request_mock.single_request(); + let req3 = third_request_mock.single_request(); - let req1 = &requests[0]; - let req2 = &requests[1]; - let req3 = &requests[2]; - - let body1 = req1.body_json::().unwrap(); - let body2 = req2.body_json::().unwrap(); - let body3 = req3.body_json::().unwrap(); + let body1 = req1.body_json(); + let body2 = req2.body_json(); + let body3 = req3.body_json(); // Manual compact should keep the baseline developer instructions. let instr1 = body1.get("instructions").and_then(|v| v.as_str()).unwrap(); From d4eda9d10b1ca8e8802cf515089b559326a89aaf Mon Sep 17 00:00:00 2001 From: easong-openai Date: Wed, 5 Nov 2025 13:23:46 -0800 Subject: [PATCH 039/122] stop capturing r when environment selection modal is open (#6249) This fixes an issue where you can't select environments with an r in them when the selection modal is open --- codex-rs/cloud-tasks/src/lib.rs | 18 +++--------------- codex-rs/cloud-tasks/src/ui.rs | 4 +--- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 091a86ec4a..8f8b3066c9 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -1044,7 +1044,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an // Close task modal/pending apply if present before opening env modal app.diff_overlay = None; app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; @@ -1115,7 +1115,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let _ = tx.send(evt); }); } else { - app.status = "No environment selected (press 'e' to choose)".to_string(); + app.status = "No environment selected".to_string(); } } needs_redraw = true; @@ -1313,18 +1313,6 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an // Environment modal key handling match key.code { KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } - KeyCode::Char('r') | KeyCode::Char('R') => { - // Trigger refresh of environments - app.env_loading = true; app.env_error = None; needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); - let tx = tx.clone(); - tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); - let headers = crate::util::build_chatgpt_headers().await; - let res = crate::env_detect::list_environments(&base_url, &headers).await; - let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); - }); - } KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } needs_redraw = true; @@ -1431,7 +1419,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an } KeyCode::Char('o') | KeyCode::Char('O') => { app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; app.env_error = None; } needs_redraw = true; diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 81353a032b..e3a97aeb3f 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -945,9 +945,7 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { // Subheader with usage hints (dim cyan) let subheader = Paragraph::new(Line::from( - "Type to search, Enter select, Esc cancel; r refresh" - .cyan() - .dim(), + "Type to search, Enter select, Esc cancel".cyan().dim(), )) .wrap(Wrap { trim: true }); frame.render_widget(subheader, rows[0]); From 05f0b4f590286536796200253252e3084b4719ff Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 5 Nov 2025 13:52:50 -0800 Subject: [PATCH 040/122] [App-server] Implement v2 for `account/login/start` and `account/login/completed` (#6183) This PR implements `account/login/start` and `account/login/completed`. Instead of having separate endpoints for login with chatgpt and api, we have a single enum handling different login methods. For sync auth methods like sign in with api key, we still send a `completed` notification back to be compatible with the async login flow. --- .../src/protocol/common.rs | 19 +- .../app-server-protocol/src/protocol/v1.rs | 3 +- .../app-server-protocol/src/protocol/v2.rs | 46 ++- .../app-server/src/codex_message_processor.rs | 352 ++++++++++++------ codex-rs/app-server/src/outgoing_message.rs | 30 +- .../app-server/tests/common/mcp_process.rs | 20 + codex-rs/app-server/tests/suite/v2/account.rs | 187 +++++++++- 7 files changed, 521 insertions(+), 136 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 275d294a90..17299fc3e4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -141,8 +141,8 @@ client_request_definitions! { response: v2::ModelListResponse, }, - #[serde(rename = "account/login")] - #[ts(rename = "account/login")] + #[serde(rename = "account/login/start")] + #[ts(rename = "account/login/start")] LoginAccount { params: v2::LoginAccountParams, response: v2::LoginAccountResponse, @@ -514,8 +514,15 @@ server_notification_definitions! { AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), + #[serde(rename = "account/login/completed")] + #[ts(rename = "account/login/completed")] + #[strum(serialize = "account/login/completed")] + AccountLoginCompleted(v2::AccountLoginCompletedNotification), + /// DEPRECATED NOTIFICATIONS below AuthStatusChange(v1::AuthStatusChangeNotification), + + /// Deprecated: use `account/login/completed` instead. LoginChatGptComplete(v1::LoginChatGptCompleteNotification), SessionConfigured(v1::SessionConfiguredNotification), } @@ -680,7 +687,7 @@ mod tests { }; assert_eq!( json!({ - "method": "account/login", + "method": "account/login/start", "id": 2, "params": { "type": "apiKey", @@ -696,11 +703,11 @@ mod tests { fn serialize_account_login_chatgpt() -> Result<()> { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), - params: v2::LoginAccountParams::ChatGpt, + params: v2::LoginAccountParams::Chatgpt, }; assert_eq!( json!({ - "method": "account/login", + "method": "account/login/start", "id": 3, "params": { "type": "chatgpt" @@ -756,7 +763,7 @@ mod tests { serde_json::to_value(&api_key)?, ); - let chatgpt = v2::Account::ChatGpt { + let chatgpt = v2::Account::Chatgpt { email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 252fb0410a..bbd825ba40 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -374,10 +374,11 @@ pub enum InputItem { LocalImage { path: PathBuf }, } -// Deprecated notifications (v1) +// Deprecated in favor of AccountLoginCompletedNotification. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +/// Deprecated: use `account/login/completed` instead. pub struct LoginChatGptCompleteNotification { #[schemars(with = "String")] pub login_id: Uuid, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f96bbf43c8..a59f00d45f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -13,7 +13,6 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; use ts_rs::TS; -use uuid::Uuid; // Macro to declare a camelCased API v2 enum mirroring a core enum which // tends to use kebab-case. @@ -126,7 +125,7 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - ChatGpt { + Chatgpt { email: Option, plan_type: PlanType, }, @@ -137,8 +136,8 @@ pub enum Account { #[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum LoginAccountParams { - #[serde(rename = "apiKey")] - #[ts(rename = "apiKey")] + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] ApiKey { #[serde(rename = "apiKey")] #[ts(rename = "apiKey")] @@ -146,20 +145,26 @@ pub enum LoginAccountParams { }, #[serde(rename = "chatgpt")] #[ts(rename = "chatgpt")] - ChatGpt, + Chatgpt, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] -pub struct LoginAccountResponse { - /// Only set if the login method is ChatGPT. - #[schemars(with = "String")] - pub login_id: Option, - - /// URL the client should open in a browser to initiate the OAuth flow. - /// Only set if the login method is ChatGPT. - pub auth_url: Option, +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -335,7 +340,7 @@ pub struct Thread { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct AccountUpdatedNotification { - pub auth_method: Option, + pub auth_mode: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -611,3 +616,14 @@ impl From for RateLimitWindow { } } } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1d7c0a4871..4c14ce3a7a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; @@ -36,6 +37,7 @@ use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; @@ -222,12 +224,8 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } - ClientRequest::LoginAccount { - request_id, - params: _, - } => { - self.send_unimplemented_error(request_id, "account/login") - .await; + ClientRequest::LoginAccount { request_id, params } => { + self.login_v2(request_id, params).await; } ClientRequest::LogoutAccount { request_id, @@ -267,13 +265,13 @@ impl CodexMessageProcessor { self.git_diff_to_origin(request_id, params.cwd).await; } ClientRequest::LoginApiKey { request_id, params } => { - self.login_api_key(request_id, params).await; + self.login_api_key_v1(request_id, params).await; } ClientRequest::LoginChatGpt { request_id, params: _, } => { - self.login_chatgpt(request_id).await; + self.login_chatgpt_v1(request_id).await; } ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; @@ -335,20 +333,34 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } - async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + async fn login_v2(&mut self, request_id: RequestId, params: LoginAccountParams) { + match params { + LoginAccountParams::ApiKey { api_key } => { + self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) + .await; + } + LoginAccountParams::Chatgpt => { + self.login_chatgpt_v2(request_id).await; + } + } + } + + async fn login_api_key_common( + &mut self, + params: &LoginApiKeyParams, + ) -> std::result::Result<(), JSONRPCErrorError> { if matches!( self.config.forced_login_method, Some(ForcedLoginMethod::Chatgpt) ) { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "API key login is disabled. Use ChatGPT login instead.".to_string(), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } + // Cancel any active login attempt. { let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { @@ -363,6 +375,19 @@ impl CodexMessageProcessor { ) { Ok(()) => { self.auth_manager.reload(); + Ok(()) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to save api key: {err}"), + data: None, + }), + } + } + + async fn login_api_key_v1(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + match self.login_api_key_common(¶ms).await { + Ok(()) => { self.outgoing .send_response(request_id, LoginApiKeyResponse {}) .await; @@ -374,31 +399,57 @@ impl CodexMessageProcessor { .send_server_notification(ServerNotification::AuthStatusChange(payload)) .await; } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to save api key: {err}"), - data: None, - }; + Err(error) => { self.outgoing.send_error(request_id, error).await; } } } - async fn login_chatgpt(&mut self, request_id: RequestId) { + async fn login_api_key_v2(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + match self.login_api_key_common(¶ms).await { + Ok(()) => { + let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; + self.outgoing.send_response(request_id, response).await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: self.auth_manager.auth().map(|auth| auth.mode), + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + // Build options for a ChatGPT login attempt; performs validation. + async fn login_chatgpt_common( + &self, + ) -> std::result::Result { let config = self.config.as_ref(); if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "ChatGPT login is disabled. Use API key login instead.".to_string(), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - let opts = LoginServerOptions { + Ok(LoginServerOptions { open_browser: false, ..LoginServerOptions::new( config.codex_home.clone(), @@ -406,99 +457,190 @@ impl CodexMessageProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, ) - }; + }) + } - enum LoginChatGptReply { - Response(LoginChatGptResponse), - Error(JSONRPCErrorError), - } + // Deprecated in favor of login_chatgpt_v2. + async fn login_chatgpt_v1(&mut self, request_id: RequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match run_login_server(opts) { + Ok(server) => { + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); - let reply = match run_login_server(opts) { - Ok(server) => { - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); - - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - existing.drop(); - } - *guard = Some(ActiveLogin { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } - - let response = LoginChatGptResponse { - login_id, - auth_url: server.auth_url.clone(), - }; - - // Spawn background task to monitor completion. - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await + // Replace active login if present. { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - // Timeout: cancel server and report - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + existing.drop(); } - }; - let payload = LoginChatGptCompleteNotification { - login_id, - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::LoginChatGptComplete(payload)) - .await; + *guard = Some(ActiveLogin { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } - // Send an auth status change notification. - if success { - // Update in-memory auth cache now that login completed. - auth_manager.reload(); + // Spawn background task to monitor completion. + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; - // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager.auth().map(|a| a.mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, + let payload = LoginChatGptCompleteNotification { + login_id, + success, + error: error_msg.clone(), }; outgoing_clone - .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .send_server_notification(ServerNotification::LoginChatGptComplete( + payload, + )) .await; + + if success { + auth_manager.reload(); + + // Notify clients with the actual current auth mode. + let current_auth_method = auth_manager.auth().map(|a| a.mode); + let payload = AuthStatusChangeNotification { + auth_method: current_auth_method, + }; + outgoing_clone + .send_server_notification(ServerNotification::AuthStatusChange( + payload, + )) + .await; + } + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + *guard = None; + } + }); + + let response = LoginChatGptResponse { login_id, auth_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start login server: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn login_chatgpt_v2(&mut self, request_id: RequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match run_login_server(opts) { + Ok(server) => { + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + existing.drop(); + } + *guard = Some(ActiveLogin { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); } - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { - *guard = None; - } - }); + // Spawn background task to monitor completion. + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; - LoginChatGptReply::Response(response) - } - Err(err) => LoginChatGptReply::Error(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - }), - }; + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; - match reply { - LoginChatGptReply::Response(resp) => { - self.outgoing.send_response(request_id, resp).await + if success { + auth_manager.reload(); + + // Notify clients with the actual current auth mode. + let current_auth_method = auth_manager.auth().map(|a| a.mode); + let payload_v2 = AccountUpdatedNotification { + auth_mode: current_auth_method, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + *guard = None; + } + }); + + let response = codex_app_server_protocol::LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start login server: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; } - LoginChatGptReply::Error(err) => self.outgoing.send_error(request_id, err).await, } } @@ -581,7 +723,7 @@ impl CodexMessageProcessor { .await; let payload_v2 = AccountUpdatedNotification { - auth_method: current_auth_method, + auth_mode: current_auth_method, }; self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4dca94e8a..f0ee6cf939 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -141,6 +141,7 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { + use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AuthMode; @@ -178,6 +179,31 @@ mod tests { ); } + #[test] + fn verify_account_login_completed_notification_serialization() { + let notification = + ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { + login_id: Some(Uuid::nil().to_string()), + success: true, + error: None, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/login/completed", + "params": { + "loginId": Uuid::nil().to_string(), + "success": true, + "error": null, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn verify_account_rate_limits_notification_serialization() { let notification = @@ -216,7 +242,7 @@ mod tests { #[test] fn verify_account_updated_notification_serialization() { let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { - auth_method: Some(AuthMode::ApiKey), + auth_mode: Some(AuthMode::ApiKey), }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); @@ -224,7 +250,7 @@ mod tests { json!({ "method": "account/updated", "params": { - "authMethod": "apikey" + "authMode": "apikey" }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 254f121a88..298d9fd414 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -366,6 +366,26 @@ impl McpProcess { self.send_request("account/logout", None).await } + /// Send an `account/login/start` JSON-RPC request for API key login. + pub async fn send_login_account_api_key_request( + &mut self, + api_key: &str, + ) -> anyhow::Result { + let params = serde_json::json!({ + "type": "apiKey", + "apiKey": api_key, + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/start` JSON-RPC request for ChatGPT login. + pub async fn send_login_account_chatgpt_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgpt" + }); + self.send_request("account/login/start", Some(params)).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 5cc3719f24..dfdc932b9a 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -2,15 +2,19 @@ use anyhow::Result; use anyhow::bail; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use pretty_assertions::assert_eq; +use serial_test::serial; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -18,14 +22,29 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // Helper to create a minimal config.toml for the app server -fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { +fn create_config_toml( + codex_home: &Path, + forced_method: Option<&str>, + forced_workspace_id: Option<&str>, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, + let forced_line = if let Some(method) = forced_method { + format!("forced_login_method = \"{method}\"\n") + } else { + String::new() + }; + let forced_workspace_line = if let Some(ws) = forced_workspace_id { + format!("forced_chatgpt_workspace_id = \"{ws}\"\n") + } else { + String::new() + }; + let contents = format!( r#" model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" +{forced_line} +{forced_workspace_line} model_provider = "mock_provider" @@ -35,14 +54,15 @@ base_url = "http://127.0.0.1:0/v1" wire_api = "chat" request_max_retries = 0 stream_max_retries = 0 -"#, - ) +"# + ); + std::fs::write(config_toml, contents) } #[tokio::test] async fn logout_account_removes_auth_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; + create_config_toml(codex_home.path(), None, None)?; login_with_api_key( codex_home.path(), @@ -72,7 +92,7 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { bail!("unexpected notification: {parsed:?}"); }; assert!( - payload.auth_method.is_none(), + payload.auth_mode.is_none(), "auth_method should be None after logout" ); @@ -97,3 +117,156 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { assert_eq!(status.auth_token, None); Ok(()) } + +#[tokio::test] +async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), None, None)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, None); + pretty_assertions::assert_eq!(payload.success, true); + pretty_assertions::assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey)); + + assert!(codex_home.path().join("auth.json").exists()); + Ok(()) +} + +#[tokio::test] +async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), Some("chatgpt"), None)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), Some("api"), None)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_start() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), None, None)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { + login_id: _, + auth_url, + } = login + else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost"), + "auth_url should contain a redirect_uri to localhost" + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), None, Some("ws-forced"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); + Ok(()) +} From 86c149ae8e8309719a4330ce25a3aff749918cd0 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 5 Nov 2025 16:25:58 -0600 Subject: [PATCH 041/122] Prevent dismissal of login menu in TUI (#6285) We currently allow the user to dismiss the login menu via Ctrl+C. This leaves them in a bad state where they're not auth'ed but have an input prompt. In the extension, this isn't a problem because we don't allow the user to dismiss the login screen. Testing: I confirmed that Ctrl+C no longer dismisses the login menu. This is an alternative (simpler) fix for a [community PR](https://github.com/openai/codex/pull/3234). --- codex-rs/tui/src/onboarding/onboarding_screen.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 709f158b80..6a1a9ff5d9 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -170,6 +170,12 @@ impl OnboardingScreen { out } + fn is_auth_in_progress(&self) -> bool { + self.steps.iter().any(|step| { + matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress) + }) + } + pub(crate) fn is_done(&self) -> bool { self.is_done || !self @@ -216,7 +222,9 @@ impl KeyboardHandler for OnboardingScreen { kind: KeyEventKind::Press, .. } => { - self.is_done = true; + if !self.is_auth_in_progress() { + self.is_done = true; + } } _ => { if let Some(Step::Welcome(widget)) = self From 4a1a7f9685aa311ec8652f1fa1d13e85fb95036b Mon Sep 17 00:00:00 2001 From: wizard <112275929+famouswizard@users.noreply.github.com> Date: Thu, 6 Nov 2025 01:52:51 +0300 Subject: [PATCH 042/122] =?UTF-8?q?fix:=20ToC=20so=20it=20doesn=E2=80=99t?= =?UTF-8?q?=20include=20itself=20or=20duplicate=20the=20end=20marker=20(#4?= =?UTF-8?q?388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit turns out the ToC was including itself when generating, which messed up comparisons and sometimes made the file rewrite endlessly. also fixed the slice so `` doesn’t get duplicated when we insert the new ToC. should behave nicely now - no extra rewrites, no doubled markers. Co-authored-by: Eric Traut --- scripts/readme_toc.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/readme_toc.py b/scripts/readme_toc.py index b6ab0a6582..90fdc3ab71 100755 --- a/scripts/readme_toc.py +++ b/scripts/readme_toc.py @@ -87,8 +87,9 @@ def check_or_fix(readme_path: Path, fix: bool) -> int: # extract current ToC list items current_block = lines[begin_idx + 1 : end_idx] current = [l for l in current_block if l.lstrip().startswith("- [")] - # generate expected ToC - expected = generate_toc_lines(content) + # generate expected ToC from content without current ToC + toc_content = lines[:begin_idx] + lines[end_idx+1:] + expected = generate_toc_lines("\n".join(toc_content)) if current == expected: return 0 if not fix: @@ -108,7 +109,7 @@ def check_or_fix(readme_path: Path, fix: bool) -> int: return 1 # rebuild file with updated ToC prefix = lines[: begin_idx + 1] - suffix = lines[end_idx:] + suffix = lines[end_idx+1:] new_lines = prefix + [""] + expected + [""] + suffix readme_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") print(f"Updated ToC in {readme_path}.") From 229d18f4d2b3abe5a68aa74301f264c3d8d36c59 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 5 Nov 2025 17:13:55 -0800 Subject: [PATCH 043/122] [App-server] Add account/login/cancel v2 endpoint (#6288) Add `account/login/cancel` v2 endpoint for auth. this is similar implementation to `cancelLoginChatgpt` v1 endpoint. --- .../src/protocol/common.rs | 8 +++ .../app-server-protocol/src/protocol/v1.rs | 4 +- .../app-server-protocol/src/protocol/v2.rs | 12 ++++ .../app-server/src/codex_message_processor.rs | 62 +++++++++++++++---- .../app-server/tests/common/mcp_process.rs | 10 +++ codex-rs/app-server/tests/suite/v2/account.rs | 47 ++++++++++++-- 6 files changed, 123 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 17299fc3e4..8ece9bbc38 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -148,6 +148,13 @@ client_request_definitions! { response: v2::LoginAccountResponse, }, + #[serde(rename = "account/login/cancel")] + #[ts(rename = "account/login/cancel")] + CancelLoginAccount { + params: v2::CancelLoginAccountParams, + response: v2::CancelLoginAccountResponse, + }, + #[serde(rename = "account/logout")] #[ts(rename = "account/logout")] LogoutAccount { @@ -235,6 +242,7 @@ client_request_definitions! { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v1::LoginChatGptResponse, }, + // DEPRECATED in favor of CancelLoginAccount CancelLoginChatGpt { params: v1::CancelLoginChatGptParams, response: v1::CancelLoginChatGptResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index bbd825ba40..ea77e09a70 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -374,11 +374,9 @@ pub enum InputItem { LocalImage { path: PathBuf }, } -// Deprecated in favor of AccountLoginCompletedNotification. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -/// Deprecated: use `account/login/completed` instead. +/// Deprecated in favor of AccountLoginCompletedNotification. pub struct LoginChatGptCompleteNotification { #[schemars(with = "String")] pub login_id: Uuid, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a59f00d45f..ccc5572356 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -167,6 +167,18 @@ pub enum LoginAccountResponse { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c14ce3a7a..ec96d5dc27 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -15,6 +15,9 @@ use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::ArchiveConversationResponse; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::ExecCommandApprovalParams; @@ -233,6 +236,9 @@ impl CodexMessageProcessor { } => { self.logout_v2(request_id).await; } + ClientRequest::CancelLoginAccount { request_id, params } => { + self.cancel_login_v2(request_id, params).await; + } ClientRequest::GetAccount { request_id, params: _, @@ -644,27 +650,59 @@ impl CodexMessageProcessor { } } - async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { + async fn cancel_login_chatgpt_common( + &mut self, + login_id: Uuid, + ) -> std::result::Result<(), JSONRPCErrorError> { let mut guard = self.active_login.lock().await; if guard.as_ref().map(|l| l.login_id) == Some(login_id) { if let Some(active) = guard.take() { active.drop(); } - drop(guard); - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::CancelLoginChatGptResponse {}, - ) - .await; + Ok(()) } else { - drop(guard); - let error = JSONRPCErrorError { + Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!("login id not found: {login_id}"), data: None, - }; - self.outgoing.send_error(request_id, error).await; + }) + } + } + + async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { + match self.cancel_login_chatgpt_common(login_id).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginChatGptResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn cancel_login_v2(&mut self, request_id: RequestId, params: CancelLoginAccountParams) { + let login_id = params.login_id; + match Uuid::parse_str(&login_id) { + Ok(uuid) => match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginAccountResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + }, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid login id: {login_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 298d9fd414..59a8f20d89 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -14,6 +14,7 @@ use anyhow::Context; use assert_cmd::prelude::*; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; @@ -386,6 +387,15 @@ impl McpProcess { self.send_request("account/login/start", Some(params)).await } + /// Send an `account/login/cancel` JSON-RPC request. + pub async fn send_cancel_login_account_request( + &mut self, + params: CancelLoginAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/cancel", params).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index dfdc932b9a..a00c97b11e 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -3,6 +3,8 @@ use anyhow::bail; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -16,6 +18,7 @@ use codex_login::login_with_api_key; use pretty_assertions::assert_eq; use serial_test::serial; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -229,17 +232,51 @@ async fn login_account_chatgpt_start() -> Result<()> { .await??; let login: LoginAccountResponse = to_response(resp)?; - let LoginAccountResponse::Chatgpt { - login_id: _, - auth_url, - } = login - else { + let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else { bail!("unexpected login response: {login:?}"); }; assert!( auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost"), "auth_url should contain a redirect_uri to localhost" ); + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let _ok: CancelLoginAccountResponse = to_response(cancel_resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, Some(login_id)); + pretty_assertions::assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when login is cancelled" + ); Ok(()) } From 63e1ef25af5878c40463bf4302be91f639793107 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 5 Nov 2025 19:42:59 -0800 Subject: [PATCH 044/122] feat: add model nudge for queries (#6286) --- codex-rs/tui/src/chatwidget.rs | 116 ++++++++++++++++++ ...tests__rate_limit_switch_prompt_popup.snap | 12 ++ codex-rs/tui/src/chatwidget/tests.rs | 81 ++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f4235d5267..aa043c6d5a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -132,6 +132,8 @@ struct RunningCommand { } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; +const NUDGE_MODEL_SLUG: &str = "gpt-5-codex"; +const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; #[derive(Default)] struct RateLimitWarningState { @@ -230,6 +232,14 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, } +#[derive(Default)] +enum RateLimitSwitchPromptState { + #[default] + Idle, + Pending, + Shown, +} + pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, @@ -242,6 +252,7 @@ pub(crate) struct ChatWidget { token_info: Option, rate_limit_snapshot: Option, rate_limit_warnings: RateLimitWarningState, + rate_limit_switch_prompt: RateLimitSwitchPromptState, // Stream lifecycle controller stream_controller: Option, running_commands: HashMap, @@ -463,6 +474,8 @@ impl ChatWidget { self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), }); + + self.maybe_show_pending_rate_limit_prompt(); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -497,6 +510,27 @@ impl ChatWidget { .and_then(|window| window.window_minutes), ); + let high_usage = snapshot + .secondary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false) + || snapshot + .primary + .as_ref() + .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false); + + if high_usage + && self.config.model != NUDGE_MODEL_SLUG + && !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; + } + let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); self.rate_limit_snapshot = Some(display); @@ -518,6 +552,7 @@ impl ChatWidget { self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream_controller = None; + self.maybe_show_pending_rate_limit_prompt(); } fn on_error(&mut self, message: String) { @@ -1001,6 +1036,7 @@ impl ChatWidget { token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, @@ -1067,6 +1103,7 @@ impl ChatWidget { token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, @@ -1666,6 +1703,85 @@ impl ChatWidget { )); } + fn lower_cost_preset(&self) -> Option { + let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); + builtin_model_presets(auth_mode) + .into_iter() + .find(|preset| preset.model == NUDGE_MODEL_SLUG) + } + + fn maybe_show_pending_rate_limit_prompt(&mut self) { + if !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + if let Some(preset) = self.lower_cost_preset() { + self.open_rate_limit_switch_prompt(preset); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; + } else { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { + let switch_model = preset.model.to_string(); + let display_name = preset.display_name.to_string(); + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + + let switch_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(switch_model.clone()), + effort: Some(Some(default_effort)), + summary: None, + })); + tx.send(AppEvent::UpdateModel(switch_model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + })]; + + let keep_actions: Vec = Vec::new(); + let description = if preset.description.is_empty() { + Some("Uses fewer credits for upcoming turns.".to_string()) + } else { + Some(preset.description.to_string()) + }; + + let items = vec![ + SelectionItem { + name: format!("Switch to {display_name}"), + description, + selected_description: None, + is_current: false, + actions: switch_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model".to_string(), + description: None, + selected_description: None, + is_current: false, + actions: keep_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Approaching rate limits".to_string()), + subtitle: Some(format!( + "You've used over 90% of your limit. Switch to {display_name} for lower credit usage?" + )), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + /// Open a popup to choose the model (stage 1). After selecting a model, /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap new file mode 100644 index 0000000000..39b07ee29e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 474 +expression: popup +--- + Approaching rate limits + You've used over 90% of your limit. Switch to gpt-5-codex for lower credit u + +› 1. Switch to gpt-5-codex Optimized for coding tasks with many tools. + 2. Keep current model + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 50ff0b25a3..91d66acd30 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -26,6 +26,7 @@ use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitWindow; use codex_core::protocol::ReviewCodeLocation; use codex_core::protocol::ReviewFinding; use codex_core::protocol::ReviewLineRange; @@ -102,6 +103,17 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json payload } +fn snapshot(percent: f64) -> RateLimitSnapshot { + RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: percent, + window_minutes: Some(60), + resets_at: None, + }), + secondary: None, + } +} + #[test] fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(); @@ -285,6 +297,7 @@ fn make_chatwidget_manual() -> ( token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, @@ -393,6 +406,74 @@ fn test_rate_limit_warnings_monthly() { ); } +#[test] +fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { + let (mut chat, _, _) = make_chatwidget_manual(); + chat.config.model = NUDGE_MODEL_SLUG.to_string(); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + +#[test] +fn rate_limit_switch_prompt_shows_once_per_session() { + let (mut chat, _, _) = make_chatwidget_manual(); + chat.config.model = "gpt-5".to_string(); + + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!( + chat.rate_limit_warnings.primary_index >= 1, + "warnings not emitted" + ); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_defers_until_task_complete() { + let (mut chat, _, _) = make_chatwidget_manual(); + chat.config.model = "gpt-5".to_string(); + + chat.bottom_pane.set_task_running(true); + chat.on_rate_limit_snapshot(Some(snapshot(90.0))); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + )); + + chat.bottom_pane.set_task_running(false); + chat.maybe_show_pending_rate_limit_prompt(); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); +} + +#[test] +fn rate_limit_switch_prompt_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + chat.config.model = "gpt-5".to_string(); + + chat.on_rate_limit_snapshot(Some(snapshot(92.0))); + chat.maybe_show_pending_rate_limit_prompt(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("rate_limit_switch_prompt_popup", popup); +} + // (removed experimental resize snapshot test) #[test] From 667e841d3e451cd895f6e7c28589c3b44814e45a Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Wed, 5 Nov 2025 23:06:45 -0800 Subject: [PATCH 045/122] feat: support models with single reasoning effort (#6300) --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/app-server/src/models.rs | 5 ++- codex-rs/app-server/tests/suite/model_list.rs | 2 +- codex-rs/common/src/model_presets.rs | 29 ++++++++++++-- codex-rs/tui/src/chatwidget.rs | 40 ++++++++++++++++++- ...twidget__tests__model_selection_popup.snap | 2 +- ...tests__rate_limit_switch_prompt_popup.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 40 +++++++++++++++++++ 8 files changed, 112 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ec96d5dc27..e280e56392 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1544,7 +1544,8 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let models = supported_models(); + let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); + let models = supported_models(auth_mode); let total = models.len(); if total == 0 { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index be47cbc2cb..d03795c2d4 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,11 +1,12 @@ +use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_common::model_presets::ModelPreset; use codex_common::model_presets::ReasoningEffortPreset; use codex_common::model_presets::builtin_model_presets; -pub fn supported_models() -> Vec { - builtin_model_presets(None) +pub fn supported_models(auth_mode: Option) -> Vec { + builtin_model_presets(auth_mode) .into_iter() .map(model_from_preset) .collect() diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/model_list.rs index b652121f58..66f6077700 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/model_list.rs @@ -49,7 +49,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { id: "gpt-5-codex".to_string(), model: "gpt-5-codex".to_string(), display_name: "gpt-5-codex".to_string(), - description: "Optimized for coding tasks with many tools.".to_string(), + description: "Optimized for codex.".to_string(), supported_reasoning_efforts: vec![ ReasoningEffortOption { reasoning_effort: ReasoningEffort::Low, diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index b6d0643809..5337f68b7e 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -34,7 +34,7 @@ const PRESETS: &[ModelPreset] = &[ id: "gpt-5-codex", model: "gpt-5-codex", display_name: "gpt-5-codex", - description: "Optimized for coding tasks with many tools.", + description: "Optimized for codex.", default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: &[ ReasoningEffortPreset { @@ -52,6 +52,24 @@ const PRESETS: &[ModelPreset] = &[ ], is_default: true, }, + ModelPreset { + id: "desertfox", + model: "desertfox", + display_name: "desertfox", + description: "???", + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: &[ + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Dynamically adjusts reasoning based on the task", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems", + }, + ], + is_default: false, + }, ModelPreset { id: "gpt-5", model: "gpt-5", @@ -80,8 +98,13 @@ const PRESETS: &[ModelPreset] = &[ }, ]; -pub fn builtin_model_presets(_auth_mode: Option) -> Vec { - PRESETS.to_vec() +pub fn builtin_model_presets(auth_mode: Option) -> Vec { + let allow_desertfox = matches!(auth_mode, Some(AuthMode::ChatGPT)); + PRESETS + .iter() + .filter(|preset| allow_desertfox || preset.id != "desertfox") + .copied() + .collect() } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aa043c6d5a..f852ae9f24 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1798,6 +1798,7 @@ impl ChatWidget { }; let is_current = preset.model == current_model; let preset_for_action = preset; + let single_supported_effort = preset_for_action.supported_reasoning_efforts.len() == 1; let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::OpenReasoningPopup { model: preset_for_action, @@ -1808,7 +1809,7 @@ impl ChatWidget { description, is_current, actions, - dismiss_on_select: false, + dismiss_on_select: single_supported_effort, ..Default::default() }); } @@ -1847,6 +1848,15 @@ impl ChatWidget { }); } + if choices.len() == 1 { + if let Some(effort) = choices.first().and_then(|c| c.stored) { + self.apply_model_and_effort(preset.model.to_string(), Some(effort)); + } else { + self.apply_model_and_effort(preset.model.to_string(), None); + } + return; + } + let default_choice: Option = choices .iter() .any(|choice| choice.stored == Some(default_effort)) @@ -1885,7 +1895,7 @@ impl ChatWidget { let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits."; let show_warning = - preset.model == "gpt-5-codex" && effort == ReasoningEffortConfig::High; + preset.model.starts_with("gpt-5-codex") && effort == ReasoningEffortConfig::High; let selected_description = show_warning.then(|| { description .as_ref() @@ -1942,6 +1952,32 @@ impl ChatWidget { }); } + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.clone()), + effort: Some(effort), + summary: None, + })); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + tracing::info!( + "Selected model: {}, Selected effort: {}", + model, + effort + .map(|e| e.to_string()) + .unwrap_or_else(|| "default".to_string()) + ); + } + /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). pub(crate) fn open_approvals_popup(&mut self) { let current_approval = self.config.approval_policy; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index d4ca0491ee..70587b9b40 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -5,7 +5,7 @@ expression: popup Select Model and Effort Switch the model for this and future Codex CLI sessions -› 1. gpt-5-codex (current) Optimized for coding tasks with many tools. +› 1. gpt-5-codex (current) Optimized for codex. 2. gpt-5 Broad world knowledge with strong general reasoning. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index 39b07ee29e..32fb3a7f39 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -6,7 +6,7 @@ expression: popup Approaching rate limits You've used over 90% of your limit. Switch to gpt-5-codex for lower credit u -› 1. Switch to gpt-5-codex Optimized for coding tasks with many tools. +› 1. Switch to gpt-5-codex Optimized for codex. 2. Keep current model Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 91d66acd30..22cc1e9253 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5,6 +5,8 @@ use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_common::approval_presets::builtin_approval_presets; +use codex_common::model_presets::ModelPreset; +use codex_common::model_presets::ReasoningEffortPreset; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -1491,6 +1493,44 @@ fn model_reasoning_selection_popup_snapshot() { assert_snapshot!("model_reasoning_selection_popup", popup); } +#[test] +fn single_reasoning_option_skips_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + static SINGLE_EFFORT: [ReasoningEffortPreset; 1] = [ReasoningEffortPreset { + effort: ReasoningEffortConfig::High, + description: "Maximizes reasoning depth for complex or ambiguous problems", + }]; + let preset = ModelPreset { + id: "model-with-single-reasoning", + model: "model-with-single-reasoning", + display_name: "model-with-single-reasoning", + description: "", + default_reasoning_effort: ReasoningEffortConfig::High, + supported_reasoning_efforts: &SINGLE_EFFORT, + is_default: false, + }; + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, 80); + assert!( + !popup.contains("Select Reasoning Level"), + "expected reasoning selection popup to be skipped" + ); + + let mut events = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } + + assert!( + events + .iter() + .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + "expected reasoning effort to be applied automatically; events: {events:?}" + ); +} + #[test] fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); From 649ce520c435d39e745d0222facdd60939c71f67 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Thu, 6 Nov 2025 08:32:57 -0800 Subject: [PATCH 046/122] chore: rename for clarity (#6319) Co-authored-by: Ahmed Ibrahim --- codex-rs/common/src/model_presets.rs | 12 ++++++------ codex-rs/tui/src/chatwidget.rs | 2 +- ...idget__tests__rate_limit_switch_prompt_popup.snap | 7 ++++--- codex-rs/tui/src/chatwidget/tests.rs | 8 ++++++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 5337f68b7e..b011fe9b60 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -53,10 +53,10 @@ const PRESETS: &[ModelPreset] = &[ is_default: true, }, ModelPreset { - id: "desertfox", - model: "desertfox", - display_name: "desertfox", - description: "???", + id: "gpt-5-codex-mini", + model: "gpt-5-codex-mini", + display_name: "gpt-5-codex-mini", + description: "Optimized for codex. Cheaper, faster, and less capable.", default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: &[ ReasoningEffortPreset { @@ -99,10 +99,10 @@ const PRESETS: &[ModelPreset] = &[ ]; pub fn builtin_model_presets(auth_mode: Option) -> Vec { - let allow_desertfox = matches!(auth_mode, Some(AuthMode::ChatGPT)); + let allow_codex_mini = matches!(auth_mode, Some(AuthMode::ChatGPT)); PRESETS .iter() - .filter(|preset| allow_desertfox || preset.id != "desertfox") + .filter(|preset| allow_codex_mini || preset.id != "gpt-5-codex-mini") .copied() .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f852ae9f24..304adc7548 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -132,7 +132,7 @@ struct RunningCommand { } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; -const NUDGE_MODEL_SLUG: &str = "gpt-5-codex"; +const NUDGE_MODEL_SLUG: &str = "gpt-5-codex-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; #[derive(Default)] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index 32fb3a7f39..c4495fe9df 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,12 +1,13 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 474 +assertion_line: 480 expression: popup --- Approaching rate limits - You've used over 90% of your limit. Switch to gpt-5-codex for lower credit u + You've used over 90% of your limit. Switch to gpt-5-codex-mini for lower cre -› 1. Switch to gpt-5-codex Optimized for codex. +› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, and + less capable. 2. Keep current model Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 22cc1e9253..e035ce3bed 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -411,6 +411,8 @@ fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { let (mut chat, _, _) = make_chatwidget_manual(); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = NUDGE_MODEL_SLUG.to_string(); chat.on_rate_limit_snapshot(Some(snapshot(95.0))); @@ -423,8 +425,10 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let (mut chat, _, _) = make_chatwidget_manual(); chat.config.model = "gpt-5".to_string(); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); assert!( @@ -446,8 +450,10 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let (mut chat, _, _) = make_chatwidget_manual(); chat.config.model = "gpt-5".to_string(); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -467,6 +473,8 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.config.model = "gpt-5".to_string(); chat.on_rate_limit_snapshot(Some(snapshot(92.0))); From 6582554926e9c474afc287c091039fdaa2eacecd Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 6 Nov 2025 08:36:36 -0800 Subject: [PATCH 047/122] [app-server] feat: v2 Turn APIs (#6216) Implements: ``` turn/start turn/interrupt ``` along with their integration tests. These are relatively light wrappers around the existing core logic, and changes to core logic are minimal. However, an improvement made for developer ergonomics: - `turn/start` replaces both `SendUserMessage` (no turn overrides) and `SendUserTurn` (can override model, approval policy, etc.) --- .../src/protocol/common.rs | 12 + .../app-server-protocol/src/protocol/v2.rs | 66 ++- .../app-server/src/codex_message_processor.rs | 304 +++++++---- .../app-server/tests/common/mcp_process.rs | 35 +- codex-rs/app-server/tests/suite/interrupt.rs | 2 +- codex-rs/app-server/tests/suite/v2/mod.rs | 3 +- .../tests/suite/v2/turn_interrupt.rs | 128 +++++ .../app-server/tests/suite/v2/turn_start.rs | 486 ++++++++++++++++++ 8 files changed, 935 insertions(+), 101 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/turn_interrupt.rs create mode 100644 codex-rs/app-server/tests/suite/v2/turn_start.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 8ece9bbc38..a7ae035d48 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -133,6 +133,18 @@ client_request_definitions! { params: v2::ThreadCompactParams, response: v2::ThreadCompactResponse, }, + #[serde(rename = "turn/start")] + #[ts(rename = "turn/start")] + TurnStart { + params: v2::TurnStartParams, + response: v2::TurnStartResponse, + }, + #[serde(rename = "turn/interrupt")] + #[ts(rename = "turn/interrupt")] + TurnInterrupt { + params: v2::TurnInterruptParams, + response: v2::TurnInterruptResponse, + }, #[serde(rename = "model/list")] #[ts(rename = "model/list")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ccc5572356..436585ba17 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5,8 +5,10 @@ use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; use schemars::JsonSchema; use serde::Deserialize; @@ -365,6 +367,13 @@ pub struct Turn { pub error: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnError { + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -375,15 +384,51 @@ pub enum TurnStatus { InProgress, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct TurnError { - pub message: String, +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Override the working directory for this turn and subsequent turns. + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + pub approval_policy: Option, + /// Override the sandbox policy for this turn and subsequent turns. + pub sandbox_policy: Option, + /// Override the model for this turn and subsequent turns. + pub model: Option, + /// Override the reasoning effort for this turn and subsequent turns. + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + pub summary: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum UserInput { Text { text: String }, @@ -391,8 +436,19 @@ pub enum UserInput { LocalImage { path: PathBuf }, } +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { text } => CoreUserInput::Text { text }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum ThreadItem { UserMessage { @@ -516,7 +572,7 @@ pub struct TodoItem { } // === Server Notifications === - +// Thread/Turn lifecycle notifications and item progress events #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -545,6 +601,7 @@ pub struct Usage { #[ts(export_to = "v2/")] pub struct TurnCompletedNotification { pub turn: Turn, + // TODO: should usage be stored on the Turn object, and we return that instead? pub usage: Usage, } @@ -562,6 +619,7 @@ pub struct ItemCompletedNotification { pub item: ThreadItem, } +// Item-specific progress notifications #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e280e56392..ddf777de4c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::ApplyPatchApprovalResponse; use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::ArchiveConversationResponse; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; use codex_app_server_protocol::CancelLoginAccountParams; @@ -29,6 +30,8 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetUserAgentResponse; @@ -45,6 +48,8 @@ use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -54,6 +59,8 @@ use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::ResumeConversationResponse; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use codex_app_server_protocol::SendUserTurnParams; @@ -66,6 +73,7 @@ use codex_app_server_protocol::SetDefaultModelResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadResumeParams; @@ -73,7 +81,15 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::Turn; +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::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInfoResponse; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserSavedConfig; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; @@ -136,6 +152,9 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>; +type PendingInterrupts = Arc>>; + // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); struct ActiveLogin { @@ -159,11 +178,17 @@ pub(crate) struct CodexMessageProcessor { conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. - pending_interrupts: Arc>>>, + pending_interrupts: PendingInterrupts, pending_fuzzy_searches: Arc>>>, feedback: CodexFeedback, } +#[derive(Clone, Copy, Debug)] +enum ApiVersion { + V1, + V2, +} + impl CodexMessageProcessor { pub fn new( auth_manager: Arc, @@ -192,7 +217,7 @@ impl CodexMessageProcessor { ClientRequest::Initialize { .. } => { panic!("Initialize should be handled in MessageProcessor"); } - // === v2 Thread APIs === + // === v2 Thread/Turn APIs === ClientRequest::ThreadStart { request_id, params } => { self.thread_start(request_id, params).await; } @@ -212,6 +237,12 @@ impl CodexMessageProcessor { self.send_unimplemented_error(request_id, "thread/compact") .await; } + ClientRequest::TurnStart { request_id, params } => { + self.turn_start(request_id, params).await; + } + ClientRequest::TurnInterrupt { request_id, params } => { + self.turn_interrupt(request_id, params).await; + } ClientRequest::NewConversation { request_id, params } => { // Do not tokio::spawn() to process new_conversation() // asynchronously because we need to ensure the conversation is @@ -731,10 +762,7 @@ impl CodexMessageProcessor { match self.logout_common().await { Ok(current_auth_method) => { self.outgoing - .send_response( - request_id, - codex_app_server_protocol::LogoutChatGptResponse {}, - ) + .send_response(request_id, LogoutChatGptResponse {}) .await; let payload = AuthStatusChangeNotification { @@ -754,10 +782,7 @@ impl CodexMessageProcessor { match self.logout_common().await { Ok(current_auth_method) => { self.outgoing - .send_response( - request_id, - codex_app_server_protocol::LogoutAccountResponse {}, - ) + .send_response(request_id, LogoutAccountResponse {}) .await; let payload_v2 = AccountUpdatedNotification { @@ -773,11 +798,7 @@ impl CodexMessageProcessor { } } - async fn get_auth_status( - &self, - request_id: RequestId, - params: codex_app_server_protocol::GetAuthStatusParams, - ) { + async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); @@ -791,7 +812,7 @@ impl CodexMessageProcessor { let requires_openai_auth = self.config.model_provider.requires_openai_auth; let response = if !requires_openai_auth { - codex_app_server_protocol::GetAuthStatusResponse { + GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(false), @@ -811,13 +832,13 @@ impl CodexMessageProcessor { (None, None) } }; - codex_app_server_protocol::GetAuthStatusResponse { + GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, requires_openai_auth: Some(true), } } - None => codex_app_server_protocol::GetAuthStatusResponse { + None => GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(true), @@ -1101,12 +1122,8 @@ impl CodexMessageProcessor { let overrides = ConfigOverrides { model: params.model, cwd: params.cwd.map(PathBuf::from), - approval_policy: params - .approval_policy - .map(codex_app_server_protocol::AskForApproval::to_core), - sandbox_mode: params - .sandbox - .map(codex_app_server_protocol::SandboxMode::to_core), + approval_policy: params.approval_policy.map(AskForApproval::to_core), + sandbox_mode: params.sandbox.map(SandboxMode::to_core), model_provider: params.model_provider, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), base_instructions: params.base_instructions, @@ -1431,60 +1448,22 @@ impl CodexMessageProcessor { let ListConversationsParams { page_size, cursor, - model_providers: model_provider, + model_providers, } = params; - let page_size = page_size.unwrap_or(25); - let cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); - let cursor_ref = cursor_obj.as_ref(); - let model_provider_filter = match model_provider { - Some(providers) => { - if providers.is_empty() { - None - } else { - Some(providers) - } - } - None => Some(vec![self.config.model_provider_id.clone()]), - }; - let model_provider_slice = model_provider_filter.as_deref(); - let fallback_provider = self.config.model_provider_id.clone(); + let page_size = page_size.unwrap_or(25).max(1); - let page = match RolloutRecorder::list_conversations( - &self.config.codex_home, - page_size, - cursor_ref, - INTERACTIVE_SESSION_SOURCES, - model_provider_slice, - fallback_provider.as_str(), - ) - .await + match self + .list_conversations_common(page_size, cursor, model_providers) + .await { - Ok(p) => p, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list conversations: {err}"), - data: None, - }; + Ok((items, next_cursor)) => { + let response = ListConversationsResponse { items, next_cursor }; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { self.outgoing.send_error(request_id, error).await; - return; } }; - - let items = page - .items - .into_iter() - .filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider)) - .collect(); - - // Encode next_cursor as a plain string - let next_cursor = page - .next_cursor - .and_then(|cursor| serde_json::to_value(&cursor).ok()) - .and_then(|value| value.as_str().map(str::to_owned)); - - let response = ListConversationsResponse { items, next_cursor }; - self.outgoing.send_response(request_id, response).await; } async fn list_conversations_common( @@ -1534,6 +1513,7 @@ impl CodexMessageProcessor { .filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider)) .collect::>(); + // Encode next_cursor as a plain string let next_cursor = page .next_cursor .and_then(|cursor| serde_json::to_value(&cursor).ok()) @@ -1557,8 +1537,8 @@ impl CodexMessageProcessor { return; } - let effective_page_size = limit.unwrap_or(total as u32).max(1) as usize; - let effective_page_size = effective_page_size.min(total); + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); let start = match cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, @@ -1585,7 +1565,7 @@ impl CodexMessageProcessor { return; } - let end = start.saturating_add(effective_page_size).min(total); + let end = start.saturating_add(effective_limit).min(total); let items = models[start..end].to_vec(); let next_cursor = if end < total { Some(end.to_string()) @@ -1620,7 +1600,7 @@ impl CodexMessageProcessor { profile, cwd, approval_policy, - sandbox, + sandbox: sandbox_mode, config: cli_overrides, base_instructions, developer_instructions, @@ -1633,7 +1613,7 @@ impl CodexMessageProcessor { config_profile: profile, cwd: cwd.map(PathBuf::from), approval_policy, - sandbox_mode: sandbox, + sandbox_mode, model_provider, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), base_instructions, @@ -1759,7 +1739,7 @@ impl CodexMessageProcessor { .map(|msgs| msgs.into_iter().collect()); // Reply with conversation id + model and initial messages (when present) - let response = codex_app_server_protocol::ResumeConversationResponse { + let response = ResumeConversationResponse { conversation_id, model: session_configured.model.clone(), initial_messages, @@ -2070,7 +2050,151 @@ impl CodexMessageProcessor { // Record the pending interrupt so we can reply when TurnAborted arrives. { let mut map = self.pending_interrupts.lock().await; - map.entry(conversation_id).or_default().push(request_id); + map.entry(conversation_id) + .or_default() + .push((request_id, ApiVersion::V1)); + } + + // Submit the interrupt; we'll respond upon TurnAborted. + let _ = conversation.submit(Op::Interrupt).await; + } + + async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) { + // Resolve conversation id from v2 thread id string. + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let Ok(conversation) = self + .conversation_manager + .get_conversation(conversation_id) + .await + else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + // Keep a copy of v2 inputs for the notification payload. + let v2_inputs_for_notif = params.input.clone(); + + // Map v2 input items to core input items. + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + + let has_any_overrides = params.cwd.is_some() + || params.approval_policy.is_some() + || params.sandbox_policy.is_some() + || params.model.is_some() + || params.effort.is_some() + || params.summary.is_some(); + + // If any overrides are provided, update the session turn context first. + if has_any_overrides { + let _ = conversation + .submit(Op::OverrideTurnContext { + cwd: params.cwd, + approval_policy: params.approval_policy.map(AskForApproval::to_core), + sandbox_policy: params.sandbox_policy.map(|p| p.to_core()), + model: params.model, + effort: params.effort.map(Some), + summary: params.summary, + }) + .await; + } + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_id = conversation + .submit(Op::UserInput { + items: mapped_items, + }) + .await; + + match turn_id { + Ok(turn_id) => { + let turn = Turn { + id: turn_id.clone(), + items: vec![ThreadItem::UserMessage { + id: turn_id, + content: v2_inputs_for_notif, + }], + status: TurnStatus::InProgress, + error: None, + }; + + let response = TurnStartResponse { turn: turn.clone() }; + self.outgoing.send_response(request_id, response).await; + + // Emit v2 turn/started notification. + let notif = TurnStartedNotification { turn }; + self.outgoing + .send_server_notification(ServerNotification::TurnStarted(notif)) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start turn: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) { + let TurnInterruptParams { thread_id, .. } = params; + + // Resolve conversation id from v2 thread id string. + let conversation_id = match ConversationId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let Ok(conversation) = self + .conversation_manager + .get_conversation(conversation_id) + .await + else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + // Record the pending interrupt so we can reply when TurnAborted arrives. + { + let mut map = self.pending_interrupts.lock().await; + map.entry(conversation_id) + .or_default() + .push((request_id, ApiVersion::V2)); } // Submit the interrupt; we'll respond upon TurnAborted. @@ -2086,7 +2210,6 @@ impl CodexMessageProcessor { conversation_id, experimental_raw_events, } = params; - match self .attach_conversation_listener(conversation_id, experimental_raw_events) .await @@ -2213,7 +2336,6 @@ impl CodexMessageProcessor { } } }); - Ok(subscription_id) } @@ -2355,7 +2477,7 @@ async fn apply_bespoke_event_handling( conversation_id: ConversationId, conversation: Arc, outgoing: Arc, - pending_interrupts: Arc>>>, + pending_interrupts: PendingInterrupts, ) { let Event { id: event_id, msg } = event; match msg { @@ -2424,11 +2546,19 @@ async fn apply_bespoke_event_handling( map.remove(&conversation_id).unwrap_or_default() }; if !pending.is_empty() { - let response = InterruptConversationResponse { - abort_reason: turn_aborted_event.reason, - }; - for rid in pending { - outgoing.send_response(rid, response.clone()).await; + for (rid, ver) in pending { + match ver { + ApiVersion::V1 => { + let response = InterruptConversationResponse { + abort_reason: turn_aborted_event.reason.clone(), + }; + outgoing.send_response(rid, response).await; + } + ApiVersion::V2 => { + let response = TurnInterruptResponse {}; + outgoing.send_response(rid, response).await; + } + } } } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 59a8f20d89..b4404b47d8 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -22,11 +22,17 @@ use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::RemoveConversationListenerParams; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ResumeConversationParams; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserTurnParams; @@ -36,13 +42,8 @@ use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadStartParams; - -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCRequest; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnStartParams; use std::process::Command as StdCommand; use tokio::process::Command; @@ -249,7 +250,7 @@ impl McpProcess { } /// Send a `feedback/upload` JSON-RPC request. - pub async fn send_upload_feedback_request( + pub async fn send_feedback_upload_request( &mut self, params: FeedbackUploadParams, ) -> anyhow::Result { @@ -348,6 +349,24 @@ impl McpProcess { self.send_request("loginChatGpt", None).await } + /// Send a `turn/start` JSON-RPC request (v2). + pub async fn send_turn_start_request( + &mut self, + params: TurnStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/start", params).await + } + + /// Send a `turn/interrupt` JSON-RPC request (v2). + pub async fn send_turn_interrupt_request( + &mut self, + params: TurnInterruptParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/interrupt", params).await + } + /// Send a `cancelLoginChatGpt` JSON-RPC request. pub async fn send_cancel_login_chat_gpt_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 1c4c05e368..86b0a3f3f8 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -146,7 +146,7 @@ fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result< r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index ccaa9edae7..df2f3d9080 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,6 +1,7 @@ -// v2 test suite modules mod account; mod thread_archive; mod thread_list; mod thread_resume; mod thread_start; +mod turn_interrupt; +mod turn_start; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs new file mode 100644 index 0000000000..d1deb60801 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -0,0 +1,128 @@ +#![cfg(unix)] + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +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::UserInput as V2UserInput; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_interrupt_aborts_running_turn() -> Result<()> { + // Use a portable sleep command to keep the turn running. + #[cfg(target_os = "windows")] + let shell_command = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 10".to_string(), + ]; + #[cfg(not(target_os = "windows"))] + let shell_command = vec!["sleep".to_string(), "10".to_string()]; + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + // Mock server: long-running shell command then (after abort) nothing else needed. + let server = create_mock_chat_completions_server(vec![create_shell_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + // Start a turn that triggers a long-running command. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + // Give the command a brief moment to start. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Interrupt the in-progress turn by id (v2 API). + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread.id, + turn_id: turn.id, + }) + .await?; + let interrupt_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)), + ) + .await??; + let _resp: TurnInterruptResponse = to_response::(interrupt_resp)?; + + // No fields to assert on; successful deserialization confirms proper response shape. + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "workspace-write" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs new file mode 100644 index 0000000000..b26f01a9f1 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -0,0 +1,486 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_mock_chat_completions_server_unchecked; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::protocol_config_types::ReasoningEffort; +use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + // Three Codex turns hit the mock model (session start + two turn/start calls). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_chat_completions_server_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread (v2) and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + // Start a turn with only input and thread_id set (no overrides). + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + // Expect a turn/started notification. + let notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!( + started.turn.status, + codex_app_server_protocol::TurnStatus::InProgress + ); + + // Send a second turn that exercises the overrides path: change the model. + let turn_req2 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Second".to_string(), + }], + model: Some("mock-model-override".to_string()), + ..Default::default() + }) + .await?; + let turn_resp2: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req2)), + ) + .await??; + let TurnStartResponse { turn: turn2 } = to_response::(turn_resp2)?; + assert!(!turn2.id.is_empty()); + // Ensure the second turn has a different id than the first. + assert_ne!(turn.id, turn2.id); + + // Expect a second turn/started notification as well. + let _notif2: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + // And we should ultimately get a task_complete without having to add a + // legacy conversation listener explicitly (auto-attached by thread/start). + let _task_complete: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_local_image_input() -> Result<()> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the request payload includes a LocalImage + // which the strict matcher does not currently cover. + let server = create_mock_chat_completions_server_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + // No need to actually write the file; we just exercise the input path. + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::LocalImage { path: image_path }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + // This test only validates that turn/start responds and returns a turn. + Ok(()) +} + +#[tokio::test] +async fn turn_start_exec_approval_toggle_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().to_path_buf(); + + // Mock server: first turn requests a shell call (elicitation), then completes. + // Second turn same, but we'll set approval_policy=never to avoid elicitation. + let responses = vec![ + create_shell_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call1", + )?, + create_final_assistant_message_sse_response("done 1")?, + create_shell_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call2", + )?, + create_final_assistant_message_sse_response("done 2")?, + ]; + let server = create_mock_chat_completions_server(responses).await; + // Default approval is untrusted to force elicitation on first turn. + create_config_toml(codex_home.as_path(), &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(start_resp)?; + + // turn/start — expect ExecCommandApproval request from server + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + }], + ..Default::default() + }) + .await?; + // Acknowledge RPC + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + + // Receive elicitation + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ExecCommandApproval { request_id, params } = server_req else { + panic!("expected ExecCommandApproval request"); + }; + assert_eq!(params.call_id, "call1"); + assert_eq!( + params.parsed_cmd, + vec![ParsedCommand::Unknown { + cmd: "python3 -c 'print(42)'".to_string() + }] + ); + + // Approve and wait for task completion + mcp.send_response( + request_id, + serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + // Second turn with approval_policy=never should not elicit approval + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python again".to_string(), + }], + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + // Ensure we do NOT receive an ExecCommandApproval request before task completes + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { + // When returning Result from a test, pass an Ok(()) to the skip macro + // so the early return type matches. The no-arg form returns unit. + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace_root = tmp.path().join("workspace"); + std::fs::create_dir(&workspace_root)?; + let first_cwd = workspace_root.join("turn1"); + let second_cwd = workspace_root.join("turn2"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; + + let responses = vec![ + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo first turn".to_string(), + ], + None, + Some(5000), + "call-first", + )?, + create_final_assistant_message_sse_response("done first")?, + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string(), + ], + None, + Some(5000), + "call-second", + )?, + create_final_assistant_message_sse_response("done second")?, + ]; + let server = create_mock_chat_completions_server(responses).await; + create_config_toml(&codex_home, &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(start_resp)?; + + // first turn with workspace-write sandbox and first_cwd + let first_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + }], + cwd: Some(first_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![first_cwd.clone()], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + let second_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + }], + cwd: Some(second_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), + ) + .await??; + + let exec_begin_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/exec_command_begin"), + ) + .await??; + let params = exec_begin_notification + .params + .clone() + .expect("exec_command_begin params"); + let event: Event = serde_json::from_value(params).expect("deserialize exec begin event"); + let exec_begin = match event.msg { + EventMsg::ExecCommandBegin(exec_begin) => exec_begin, + other => panic!("expected ExecCommandBegin event, got {other:?}"), + }; + assert_eq!(exec_begin.cwd, second_cwd); + assert_eq!( + exec_begin.command, + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string() + ] + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} From 4b4252210b5021c7e647432b19122704fa952ae8 Mon Sep 17 00:00:00 2001 From: vladislav doster Date: Thu, 6 Nov 2025 11:00:28 -0600 Subject: [PATCH 048/122] docs: Fix code fence and typo in advanced guide (#6295) - add `bash` to code fence - fix spelling of `JavaScript` --- docs/advanced.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 8fa24955c5..1d69e4d54f 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -12,7 +12,7 @@ Because Codex is written in Rust, it honors the `RUST_LOG` environment variable The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info,codex_rmcp_client=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: -``` +```bash tail -F ~/.codex/log/codex-tui.log ``` @@ -67,7 +67,7 @@ Use the MCP inspector and `codex mcp-server` to build a simple tic-tac-toe game **approval-policy:** never -**prompt:** Implement a simple tic-tac-toe game with HTML, Javascript, and CSS. Write the game in a single file called index.html. +**prompt:** Implement a simple tic-tac-toe game with HTML, JavaScript, and CSS. Write the game in a single file called index.html. **sandbox:** workspace-write From dbad5eeec6c0554eb342ffb713e5b6669a121e65 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 6 Nov 2025 09:48:59 -0800 Subject: [PATCH 049/122] chore: fix grammar mistakes (#6326) --- codex-rs/common/src/model_presets.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 4 +--- ...i__chatwidget__tests__rate_limit_switch_prompt_popup.snap | 5 ++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index b011fe9b60..5f0cdf8fb8 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -56,7 +56,7 @@ const PRESETS: &[ModelPreset] = &[ id: "gpt-5-codex-mini", model: "gpt-5-codex-mini", display_name: "gpt-5-codex-mini", - description: "Optimized for codex. Cheaper, faster, and less capable.", + description: "Optimized for codex. Cheaper, faster, but less capable.", default_reasoning_effort: ReasoningEffort::Medium, supported_reasoning_efforts: &[ ReasoningEffortPreset { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 304adc7548..72b64faca1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1773,9 +1773,7 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Approaching rate limits".to_string()), - subtitle: Some(format!( - "You've used over 90% of your limit. Switch to {display_name} for lower credit usage?" - )), + subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index c4495fe9df..f21400e1bd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,12 +1,11 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 480 expression: popup --- Approaching rate limits - You've used over 90% of your limit. Switch to gpt-5-codex-mini for lower cre + Switch to gpt-5-codex-mini for lower credit usage? -› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, and +› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, but less capable. 2. Keep current model From 871d442b8e3591866ec4e51811410f488e4c14ea Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 6 Nov 2025 10:44:42 -0800 Subject: [PATCH 050/122] Windows Sandbox: Show Everyone-writable directory warning (#6283) Show a warning when Auto Sandbox mode becomes enabled, if we detect Everyone-writable directories, since they cannot be protected by the current implementation of the Sandbox. This PR also includes changes to how we detect Everyone-writable to be *much* faster --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 2 +- codex-rs/core/src/config/edit.rs | 13 ++ codex-rs/core/src/config/types.rs | 2 + codex-rs/tui/Cargo.toml | 19 +- codex-rs/tui/src/app.rs | 104 +++++++++ codex-rs/tui/src/app_event.rs | 22 ++ codex-rs/tui/src/chatwidget.rs | 149 ++++++++++++- codex-rs/windows-sandbox-rs/src/audit.rs | 234 +++++++++++++++++---- codex-rs/windows-sandbox-rs/src/logging.rs | 5 + 10 files changed, 497 insertions(+), 54 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c4121da244..e8ce0bfe6a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1452,6 +1452,7 @@ dependencies = [ "codex-login", "codex-ollama", "codex-protocol", + "codex-windows-sandbox", "color-eyre", "crossterm", "diffy", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 36f31d2e88..619dbb5658 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -87,7 +87,7 @@ codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-utils-tokenizer = { path = "utils/tokenizer" } -codex-windows-sandbox = { path = "windows-sandbox" } +codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index c8e3bfc328..64974c2445 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -23,6 +23,8 @@ pub enum ConfigEdit { }, /// Toggle the acknowledgement flag under `[notice]`. SetNoticeHideFullAccessWarning(bool), + /// Toggle the Windows world-writable directories warning acknowledgement flag. + SetNoticeHideWorldWritableWarning(bool), /// Toggle the Windows onboarding acknowledgement flag. SetWindowsWslSetupAcknowledged(bool), /// Replace the entire `[mcp_servers]` table. @@ -239,6 +241,11 @@ impl ConfigDocument { &[Notice::TABLE_KEY, "hide_full_access_warning"], value(*acknowledged), )), + ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( + Scope::Global, + &[Notice::TABLE_KEY, "hide_world_writable_warning"], + value(*acknowledged), + )), ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( Scope::Global, &["windows_wsl_setup_acknowledged"], @@ -473,6 +480,12 @@ impl ConfigEditsBuilder { self } + pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged)); + self + } + pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index fd93df8f91..a5fd71d173 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -358,6 +358,8 @@ pub struct Tui { pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, + /// Tracks whether the user has acknowledged the Windows world-writable directories warning. + pub hide_world_writable_warning: Option, } impl Notice { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c56aba0fcf..ac00e9054b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -27,6 +27,7 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = [ "cli", @@ -34,17 +35,13 @@ codex-common = { workspace = true, features = [ "sandbox_summary", ] } codex-core = { workspace = true } +codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } -codex-feedback = { workspace = true } color-eyre = { workspace = true } -crossterm = { workspace = true, features = [ - "bracketed-paste", - "event-stream", -] } +crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } @@ -52,6 +49,7 @@ image = { workspace = true, features = ["jpeg", "png"] } itertools = { workspace = true } lazy_static = { workspace = true } mcp-types = { workspace = true } +opentelemetry-appender-tracing = { workspace = true } pathdiff = { workspace = true } pulldown-cmark = { workspace = true } rand = { workspace = true } @@ -71,8 +69,6 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } -tree-sitter-highlight = { workspace = true } -tree-sitter-bash = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -85,11 +81,14 @@ toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } -opentelemetry-appender-tracing = { workspace = true } +tree-sitter-bash = { workspace = true } +tree-sitter-highlight = { workspace = true } unicode-segmentation = { workspace = true } unicode-width = { workspace = true } url = { workspace = true } +codex-windows-sandbox = { workspace = true } + [target.'cfg(unix)'.dependencies] libc = { workspace = true } @@ -105,5 +104,5 @@ chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } rand = { workspace = true } -vt100 = { workspace = true } serial_test = { workspace = true } +vt100 = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d2c9690d5..fbf3d2a547 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -79,6 +79,9 @@ pub(crate) struct App { pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, + + // One-shot suppression of the next world-writable scan after user confirmation. + skip_world_writable_scan_once: bool, } impl App { @@ -168,8 +171,30 @@ impl App { backtrack: BacktrackState::default(), feedback: feedback.clone(), pending_update_action: None, + skip_world_writable_scan_once: false, }; + // On startup, if Auto mode (workspace-write) is active, warn about world-writable dirs on Windows. + #[cfg(target_os = "windows")] + { + let should_check = codex_core::get_platform_sandbox().is_some() + && matches!( + app.config.sandbox_policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + ) + && !app + .config + .notices + .hide_world_writable_warning + .unwrap_or(false); + if should_check { + let cwd = app.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let tx = app.app_event_tx.clone(); + Self::spawn_world_writable_scan(cwd, env_map, tx, false); + } + } + #[cfg(not(debug_assertions))] if let Some(latest_version) = upgrade_version { app.handle_event( @@ -360,6 +385,10 @@ impl App { AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } + AppEvent::OpenWorldWritableWarningConfirmation { preset } => { + self.chat_widget + .open_world_writable_warning_confirmation(preset); + } AppEvent::OpenFeedbackNote { category, include_logs, @@ -418,11 +447,45 @@ impl App { self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { + #[cfg(target_os = "windows")] + let policy_is_workspace_write = matches!( + policy, + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + ); + self.chat_widget.set_sandbox_policy(policy); + + // If sandbox policy becomes workspace-write, run the Windows world-writable scan. + #[cfg(target_os = "windows")] + { + // One-shot suppression if the user just confirmed continue. + if self.skip_world_writable_scan_once { + self.skip_world_writable_scan_once = false; + return Ok(true); + } + + let should_check = codex_core::get_platform_sandbox().is_some() + && policy_is_workspace_write + && !self.chat_widget.world_writable_warning_hidden(); + if should_check { + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let tx = self.app_event_tx.clone(); + Self::spawn_world_writable_scan(cwd, env_map, tx, false); + } + } + } + AppEvent::SkipNextWorldWritableScan => { + self.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); } + AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { + self.chat_widget + .set_world_writable_warning_acknowledged(ack); + } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(true) @@ -438,6 +501,21 @@ impl App { )); } } + AppEvent::PersistWorldWritableWarningAcknowledged => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_world_writable_warning(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist world-writable warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save Auto mode warning preference: {err}" + )); + } + } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } @@ -541,6 +619,31 @@ impl App { } }; } + + #[cfg(target_os = "windows")] + fn spawn_world_writable_scan( + cwd: PathBuf, + env_map: std::collections::HashMap, + tx: AppEventSender, + apply_preset_on_continue: bool, + ) { + tokio::task::spawn_blocking(move || { + if codex_windows_sandbox::preflight_audit_everyone_writable(&cwd, &env_map).is_err() { + if apply_preset_on_continue { + if let Some(preset) = codex_common::approval_presets::builtin_approval_presets() + .into_iter() + .find(|p| p.id == "auto") + { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset), + }); + } + } else { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None }); + } + } + }); + } } #[cfg(test)] @@ -592,6 +695,7 @@ mod tests { backtrack: BacktrackState::default(), feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, + skip_world_writable_scan_once: false, } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 514e234743..f14951a999 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -72,7 +72,17 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Open the Windows world-writable directories warning. + /// If `preset` is `Some`, the confirmation will apply the provided + /// approval/sandbox configuration on Continue; if `None`, it performs no + /// policy change and only acknowledges/dismisses the warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWorldWritableWarningConfirmation { + preset: Option, + }, + /// Show Windows Subsystem for Linux setup instructions for auto mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] ShowWindowsAutoModeInstructions, /// Update the current approval policy in the running app and widget. @@ -84,9 +94,21 @@ pub(crate) enum AppEvent { /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), + /// Update whether the world-writable directories warning has been acknowledged. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + UpdateWorldWritableWarningAcknowledged(bool), + /// Persist the acknowledgement flag for the full access warning prompt. PersistFullAccessWarningAcknowledged, + /// Persist the acknowledgement flag for the world-writable directories warning. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + PersistWorldWritableWarningAcknowledged, + + /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + SkipNextWorldWritableScan, + /// Re-open the approval presets popup. OpenApprovalsPopup, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 72b64faca1..30f9b66f00 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2030,13 +2030,34 @@ impl ChatWidget { preset: preset_clone.clone(), }); })] - } else if cfg!(target_os = "windows") - && preset.id == "auto" - && codex_core::get_platform_sandbox().is_none() - { - vec![Box::new(|tx| { - tx.send(AppEvent::ShowWindowsAutoModeInstructions); - })] + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if codex_core::get_platform_sandbox().is_none() { + vec![Box::new(|tx| { + tx.send(AppEvent::ShowWindowsAutoModeInstructions); + })] + } else if !self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + && self.windows_world_writable_flagged() + { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + } } else { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) }; @@ -2078,6 +2099,19 @@ impl ChatWidget { })] } + #[cfg(target_os = "windows")] + fn windows_world_writable_flagged(&self) -> bool { + use std::collections::HashMap; + let mut env_map: HashMap = HashMap::new(); + for (k, v) in std::env::vars() { + env_map.insert(k, v); + } + match codex_windows_sandbox::preflight_audit_everyone_writable(&self.config.cwd, &env_map) { + Ok(()) => false, + Err(_) => true, + } + } + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { let approval = preset.approval; let sandbox = preset.sandbox; @@ -2142,6 +2176,95 @@ impl ChatWidget { }); } + #[cfg(target_os = "windows")] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + preset: Option, + ) { + let (approval, sandbox) = match &preset { + Some(p) => (Some(p.approval), Some(p.sandbox.clone())), + None => (None, None), + }; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Auto mode has unprotected directories").bold(); + let info_line = Line::from(vec![ + "Some important directories on this system are world-writable. ".into(), + "The Windows sandbox cannot protect writes to these locations in Auto mode." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + // Build actions ensuring acknowledgement happens before applying the new sandbox policy, + // so downstream policy-change hooks don't re-trigger the warning. + let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan once after user confirms continue. + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { + accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let mut accept_and_remember_actions: Vec = Vec::new(); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })); + if let (Some(approval), Some(sandbox)) = (approval, sandbox) { + accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + } + + let deny_actions: Vec = if preset.is_some() { + vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })] + } else { + Vec::new() + }; + + let items = vec![ + SelectionItem { + name: "Continue".to_string(), + description: Some("Apply Auto mode for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Continue and don't warn again".to_string(), + description: Some("Enable Auto mode and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling Auto mode".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + _preset: Option, + ) { + } + #[cfg(target_os = "windows")] pub(crate) fn open_windows_auto_mode_instructions(&mut self) { use ratatui_macros::line; @@ -2193,6 +2316,18 @@ impl ChatWidget { self.config.notices.hide_full_access_warning = Some(acknowledged); } + pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_world_writable_warning = Some(acknowledged); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn world_writable_warning_hidden(&self) -> bool { + self.config + .notices + .hide_world_writable_warning + .unwrap_or(false) + } + /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 3f8ae4ff13..71dceaa532 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,4 +1,3 @@ -use crate::acl::dacl_effective_allows_write; use crate::token::world_sid; use crate::winutil::to_wide; use anyhow::anyhow; @@ -13,8 +12,31 @@ use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; +use windows_sys::Win32::Security::Authorization::GetSecurityInfo; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; +use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; +const GENERIC_ALL_MASK: u32 = 0x1000_0000; +const GENERIC_WRITE_MASK: u32 = 0x4000_0000; use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; +use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::GetAce; +use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; +use windows_sys::Win32::Security::ACE_HEADER; +use windows_sys::Win32::Security::EqualSid; fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { if let Ok(abs) = p.canonicalize() { @@ -27,30 +49,22 @@ fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { fn gather_candidates(cwd: &Path, env: &std::collections::HashMap) -> Vec { let mut set: HashSet = HashSet::new(); let mut out: Vec = Vec::new(); - // Core roots - for p in [ - PathBuf::from("C:/"), - PathBuf::from("C:/Windows"), - PathBuf::from("C:/ProgramData"), - ] { - unique_push(&mut set, &mut out, p); + // 1) CWD first (so immediate children get scanned early) + unique_push(&mut set, &mut out, cwd.to_path_buf()); + // 2) TEMP/TMP next (often small, quick to scan) + for k in ["TEMP", "TMP"] { + if let Some(v) = env.get(k).cloned().or_else(|| std::env::var(k).ok()) { + unique_push(&mut set, &mut out, PathBuf::from(v)); + } } - // User roots + // 3) User roots if let Some(up) = std::env::var_os("USERPROFILE") { unique_push(&mut set, &mut out, PathBuf::from(up)); } if let Some(pubp) = std::env::var_os("PUBLIC") { unique_push(&mut set, &mut out, PathBuf::from(pubp)); } - // CWD - unique_push(&mut set, &mut out, cwd.to_path_buf()); - // TEMP/TMP - for k in ["TEMP", "TMP"] { - if let Some(v) = env.get(k).cloned().or_else(|| std::env::var(k).ok()) { - unique_push(&mut set, &mut out, PathBuf::from(v)); - } - } - // PATH entries + // 4) PATH entries (best-effort) if let Some(path) = env .get("PATH") .cloned() @@ -62,31 +76,85 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap } } } + // 5) Core system roots last + for p in [ + PathBuf::from("C:/"), + PathBuf::from("C:/Windows"), + PathBuf::from("C:/ProgramData"), + ] { + unique_push(&mut set, &mut out, p); + } out } unsafe fn path_has_world_write_allow(path: &Path) -> Result { + // Prefer handle-based query (often faster than name-based), fallback to name-based on error let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); - let code = GetNamedSecurityInfoW( - to_wide(path).as_ptr(), - 1, - DACL_SECURITY_INFORMATION, + + let mut try_named = false; + let wpath = to_wide(path); + let h = CreateFileW( + wpath.as_ptr(), + 0x00020000, // READ_CONTROL + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + 0, ); - if code != ERROR_SUCCESS { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); + if h == INVALID_HANDLE_VALUE { + try_named = true; + } else { + let code = GetSecurityInfo( + h, + 1, // SE_FILE_OBJECT + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + CloseHandle(h); + if code != ERROR_SUCCESS { + try_named = true; + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + p_sd = std::ptr::null_mut(); + p_dacl = std::ptr::null_mut(); + } } - return Ok(false); } + + if try_named { + let code = GetNamedSecurityInfoW( + wpath.as_ptr(), + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + if code != ERROR_SUCCESS { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Ok(false); + } + } + let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; - let has = dacl_effective_allows_write(p_dacl, psid_world); + // Very fast mask-based check for world-writable grants (includes GENERIC_*). + if !dacl_quick_world_write_mask_allows(p_dacl, psid_world) { + if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); } + return Ok(false); + } + // Quick detector flagged a write grant for Everyone: treat as writable. + let has = true; if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); } @@ -100,18 +168,41 @@ pub fn audit_everyone_writable( let start = Instant::now(); let mut flagged: Vec = Vec::new(); let mut checked = 0usize; + // Fast path: check CWD immediate children first so workspace issues are caught early. + if let Ok(read) = std::fs::read_dir(cwd) { + for ent in read.flatten().take(250) { + if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + break; + } + let ft = match ent.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if ft.is_symlink() || !ft.is_dir() { + continue; + } + let p = ent.path(); + checked += 1; + let has = unsafe { path_has_world_write_allow(&p)? }; + if has { + flagged.push(p); + } + } + } + // Continue with broader candidate sweep let candidates = gather_candidates(cwd, env); for root in candidates { if start.elapsed() > Duration::from_secs(5) || checked > 5000 { break; } checked += 1; - if unsafe { path_has_world_write_allow(&root)? } { + let has_root = unsafe { path_has_world_write_allow(&root)? }; + if has_root { flagged.push(root.clone()); } // one level down best-effort if let Ok(read) = std::fs::read_dir(&root) { - for ent in read.flatten().take(50) { + for ent in read.flatten().take(250) { let p = ent.path(); if start.elapsed() > Duration::from_secs(5) || checked > 5000 { break; @@ -126,22 +217,93 @@ pub fn audit_everyone_writable( } if ft.is_dir() { checked += 1; - if unsafe { path_has_world_write_allow(&p)? } { + let has_child = unsafe { path_has_world_write_allow(&p)? }; + if has_child { flagged.push(p); } } } } } + let elapsed_ms = start.elapsed().as_millis(); if !flagged.is_empty() { let mut list = String::new(); - for p in flagged { + for p in &flagged { list.push_str(&format!("\n - {}", p.display())); } + crate::logging::log_note( + &format!( + "AUDIT: world-writable scan FAILED; checked={checked}; duration_ms={elapsed_ms}; flagged:{}", + list + ), + Some(cwd), + ); + let mut list_err = String::new(); + for p in flagged { + list_err.push_str(&format!("\n - {}", p.display())); + } return Err(anyhow!( "Refusing to run: found directories writable by Everyone: {}", - list + list_err )); } + // Log success once if nothing flagged + crate::logging::log_note( + &format!( + "AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}" + ), + Some(cwd), + ); Ok(()) } +// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for +// Everyone that includes generic or specific write bits? Skips inherit-only +// ACEs (do not apply to the current object). +unsafe fn dacl_quick_world_write_mask_allows(p_dacl: *mut ACL, psid_world: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + const INHERIT_ONLY_ACE: u8 = 0x08; + let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); + let ok = GetAclInformation( + p_dacl as *const ACL, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ); + if ok == 0 { + return false; + } + for i in 0..(info.AceCount as usize) { + let mut p_ace: *mut c_void = std::ptr::null_mut(); + if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { + continue; + } + let hdr = &*(p_ace as *const ACE_HEADER); + if hdr.AceType != 0 { // ACCESS_ALLOWED_ACE_TYPE + continue; + } + if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + let base = p_ace as usize; + let sid_ptr = (base + + std::mem::size_of::() + + std::mem::size_of::()) as *mut c_void; // skip header + mask + if EqualSid(sid_ptr, psid_world) != 0 { + let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); + let mask = ace.Mask; + let writey = FILE_GENERIC_WRITE + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | GENERIC_WRITE_MASK + | GENERIC_ALL_MASK; + if (mask & writey) != 0 { + return true; + } + } + } + false +} diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 42d6d80731..9887ce81d2 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -55,3 +55,8 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { eprintln!("{msg}"); } } + +// Unconditional note logging to sandbox_commands.rust.log +pub fn log_note(msg: &str, base_dir: Option<&Path>) { + append_line(msg, base_dir); +} From fdb9fa301e5b3119af9883937bc3afbd960e84ac Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 6 Nov 2025 10:53:17 -0800 Subject: [PATCH 051/122] chore: move relevant tests to app-server/tests/suite/v2 (#6289) These are technically app-server v2 APIs, so move them to the same directory as the others. --- codex-rs/app-server/tests/suite/mod.rs | 2 -- codex-rs/app-server/tests/suite/v2/mod.rs | 2 ++ codex-rs/app-server/tests/suite/{ => v2}/model_list.rs | 6 +++--- codex-rs/app-server/tests/suite/{ => v2}/rate_limits.rs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename codex-rs/app-server/tests/suite/{ => v2}/model_list.rs (97%) rename codex-rs/app-server/tests/suite/{ => v2}/rate_limits.rs (97%) diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index ce8b69a030..37f7659f42 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -7,8 +7,6 @@ mod fuzzy_file_search; mod interrupt; mod list_resume; mod login; -mod model_list; -mod rate_limits; mod send_message; mod set_default_model; mod user_agent; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index df2f3d9080..587afef108 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,4 +1,6 @@ mod account; +mod model_list; +mod rate_limits; mod thread_archive; mod thread_list; mod thread_resume; diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs similarity index 97% rename from codex-rs/app-server/tests/suite/model_list.rs rename to codex-rs/app-server/tests/suite/v2/model_list.rs index 66f6077700..667d9e0012 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -19,7 +19,7 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -106,7 +106,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -159,7 +159,7 @@ async fn list_models_pagination_works() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs similarity index 97% rename from codex-rs/app-server/tests/suite/rate_limits.rs rename to codex-rs/app-server/tests/suite/v2/rate_limits.rs index c6ef7dccea..d0cba83667 100644 --- a/codex-rs/app-server/tests/suite/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -26,7 +26,7 @@ use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn get_account_rate_limits_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -51,7 +51,7 @@ async fn get_account_rate_limits_requires_auth() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -78,7 +78,7 @@ async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn get_account_rate_limits_returns_snapshot() -> Result<()> { let codex_home = TempDir::new()?; write_chatgpt_auth( From 8c75ed39d5bb94159d21072d7384765d94a9012b Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Thu, 6 Nov 2025 11:42:47 -0800 Subject: [PATCH 052/122] feat: clarify that gpt-5-codex should not amend commits unless requested (#6333) --- codex-rs/core/gpt_5_codex_prompt.md | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 3abec0c831..e3cbfa0f25 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -16,6 +16,7 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. - While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. - **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. From fe7eb181046a4c4469e0343bb5218a9dc5a78691 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 6 Nov 2025 14:02:39 -0600 Subject: [PATCH 053/122] Updated contributing guidelines and PR template to request link to bug report in PR notes (#6332) Some PRs are being submitted without reference to existing bug reports or feature requests. This updates the PR template and contributing guidelines to request that all PRs from the community contain such a link. This provides additional context and helps prioritize, track, and assess PRs. --- .github/pull_request_template.md | 2 ++ docs/contributing.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0fc0747dda..45322e4ffa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,3 +4,5 @@ Before opening this Pull Request, please read the dedicated "Contributing" markd 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. diff --git a/docs/contributing.md b/docs/contributing.md index 596afe3b2e..fc3d5ce836 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -24,6 +24,7 @@ If you want to add a new feature or change the behavior of an existing one, plea ### Opening a pull request - Fill in the PR template (or include similar information) - **What? Why? How?** +- Include a link to a bug report or enhancement request in the issue tracker - Run **all** checks locally (`cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item`). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. From 8501b0b768c73e503c6e677b69b403f5f1222795 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:47:20 -0800 Subject: [PATCH 054/122] core: widen sandbox to allow certificate ops when network is enabled (#5980) This allows `gh api` to work in the workspace-write sandbox w/ network enabled. Without this we see e.g. ``` $ codex debug seatbelt --full-auto gh api repos/openai/codex/pulls --paginate -X GET -F state=all Get "https://api.github.com/repos/openai/codex/pulls?per_page=100&state=all": tls: failed to verify certificate: x509: OSStatus -26276 ``` --- codex-rs/cli/src/debug_sandbox.rs | 12 +++ codex-rs/core/src/exec.rs | 4 + codex-rs/core/src/sandboxing/mod.rs | 9 +++ codex-rs/core/src/seatbelt.rs | 79 +++++++++++++------ .../core/src/seatbelt_network_policy.sbpl | 30 +++++++ 5 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 codex-rs/core/src/seatbelt_network_policy.sbpl diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2b90cb932d..6e7cd07701 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -5,6 +5,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::exec_env::create_env; use codex_core::landlock::spawn_command_under_linux_sandbox; +#[cfg(target_os = "macos")] use codex_core::seatbelt::spawn_command_under_seatbelt; use codex_core::spawn::StdioPolicy; use codex_protocol::config_types::SandboxMode; @@ -14,6 +15,7 @@ use crate::SeatbeltCommand; use crate::WindowsCommand; use crate::exit_status::handle_exit_status; +#[cfg(target_os = "macos")] pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, @@ -33,6 +35,14 @@ pub async fn run_command_under_seatbelt( .await } +#[cfg(not(target_os = "macos"))] +pub async fn run_command_under_seatbelt( + _command: SeatbeltCommand, + _codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + anyhow::bail!("Seatbelt sandbox is only available on macOS"); +} + pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, @@ -72,6 +82,7 @@ pub async fn run_command_under_windows( } enum SandboxType { + #[cfg(target_os = "macos")] Seatbelt, Landlock, Windows, @@ -168,6 +179,7 @@ async fn run_command_under_sandbox( } let mut child = match sandbox_type { + #[cfg(target_os = "macos")] SandboxType::Seatbelt => { spawn_command_under_seatbelt( command, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index b4dacd9af6..31a611775a 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -313,6 +313,10 @@ pub(crate) mod errors { SandboxTransformError::MissingLinuxSandboxExecutable => { CodexErr::LandlockSandboxExecutableNotProvided } + #[cfg(not(target_os = "macos"))] + SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( + "seatbelt sandbox is only available on macOS".to_string(), + ), } } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 608b39ceef..5e564f5109 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,8 +14,11 @@ use crate::exec::StdoutStream; use crate::exec::execute_exec_env; use crate::landlock::create_linux_sandbox_command_args; use crate::protocol::SandboxPolicy; +#[cfg(target_os = "macos")] use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +#[cfg(target_os = "macos")] use crate::seatbelt::create_seatbelt_command_args; +#[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; @@ -56,6 +59,9 @@ pub enum SandboxPreference { pub(crate) enum SandboxTransformError { #[error("missing codex-linux-sandbox executable path")] MissingLinuxSandboxExecutable, + #[cfg(not(target_os = "macos"))] + #[error("seatbelt sandbox is only available on macOS")] + SeatbeltUnavailable, } #[derive(Default)] @@ -107,6 +113,7 @@ impl SandboxManager { let (command, sandbox_env, arg0_override) = match sandbox { SandboxType::None => (command, HashMap::new(), None), + #[cfg(target_os = "macos")] SandboxType::MacosSeatbelt => { let mut seatbelt_env = HashMap::new(); seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); @@ -117,6 +124,8 @@ impl SandboxManager { full_command.append(&mut args); (full_command, seatbelt_env, None) } + #[cfg(not(target_os = "macos"))] + SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), SandboxType::LinuxSeccomp => { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index abd88d41bf..8ca7e4357e 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,4 +1,7 @@ +#![cfg(target_os = "macos")] + use std::collections::HashMap; +use std::ffi::CStr; use std::path::Path; use std::path::PathBuf; use tokio::process::Child; @@ -9,6 +12,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); +const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); /// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` /// to defend against an attacker trying to inject a malicious version on the @@ -44,27 +48,24 @@ pub(crate) fn create_seatbelt_command_args( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, ) -> Vec { - let (file_write_policy, extra_cli_args) = { + let (file_write_policy, file_write_dir_params) = { if sandbox_policy.has_full_disk_write_access() { // Allegedly, this is more permissive than `(allow file-write*)`. ( r#"(allow file-write* (regex #"^/"))"#.to_string(), - Vec::::new(), + Vec::new(), ) } else { let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); let mut writable_folder_policies: Vec = Vec::new(); - let mut cli_args: Vec = Vec::new(); + let mut file_write_params = Vec::new(); for (index, wr) in writable_roots.iter().enumerate() { // Canonicalize to avoid mismatches like /var vs /private/var on macOS. let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone()); let root_param = format!("WRITABLE_ROOT_{index}"); - cli_args.push(format!( - "-D{root_param}={}", - canonical_root.to_string_lossy() - )); + file_write_params.push((root_param.clone(), canonical_root)); if wr.read_only_subpaths.is_empty() { writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))")); @@ -76,9 +77,9 @@ pub(crate) fn create_seatbelt_command_args( for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() { let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone()); let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"); - cli_args.push(format!("-D{ro_param}={}", canonical_ro.to_string_lossy())); require_parts .push(format!("(require-not (subpath (param \"{ro_param}\")))")); + file_write_params.push((ro_param, canonical_ro)); } let policy_component = format!("(require-all {} )", require_parts.join(" ")); writable_folder_policies.push(policy_component); @@ -86,13 +87,13 @@ pub(crate) fn create_seatbelt_command_args( } if writable_folder_policies.is_empty() { - ("".to_string(), Vec::::new()) + ("".to_string(), Vec::new()) } else { let file_write_policy = format!( "(allow file-write*\n{}\n)", writable_folder_policies.join(" ") ); - (file_write_policy, cli_args) + (file_write_policy, file_write_params) } } }; @@ -105,7 +106,7 @@ pub(crate) fn create_seatbelt_command_args( // TODO(mbolin): apply_patch calls must also honor the SandboxPolicy. let network_policy = if sandbox_policy.has_full_network_access() { - "(allow network-outbound)\n(allow network-inbound)\n(allow system-socket)" + MACOS_SEATBELT_NETWORK_POLICY } else { "" }; @@ -114,17 +115,49 @@ pub(crate) fn create_seatbelt_command_args( "{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}" ); + let dir_params = [file_write_dir_params, macos_dir_params()].concat(); + let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; - seatbelt_args.extend(extra_cli_args); + let definition_args = dir_params + .into_iter() + .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())); + seatbelt_args.extend(definition_args); seatbelt_args.push("--".to_string()); seatbelt_args.extend(command); seatbelt_args } +/// Wraps libc::confstr to return a String. +fn confstr(name: libc::c_int) -> Option { + let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; + let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; + if len == 0 { + return None; + } + // confstr guarantees NUL-termination when len > 0. + let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; + cstr.to_str().ok().map(ToString::to_string) +} + +/// Wraps confstr to return a canonicalized PathBuf. +fn confstr_path(name: libc::c_int) -> Option { + let s = confstr(name)?; + let path = PathBuf::from(s); + path.canonicalize().ok().or(Some(path)) +} + +fn macos_dir_params() -> Vec<(String, PathBuf)> { + if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { + return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; + } + vec![] +} + #[cfg(test)] mod tests { use super::MACOS_SEATBELT_BASE_POLICY; use super::create_seatbelt_command_args; + use super::macos_dir_params; use crate::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; @@ -134,11 +167,6 @@ mod tests { #[test] fn create_seatbelt_args_with_read_only_git_subpath() { - if cfg!(target_os = "windows") { - // /tmp does not exist on Windows, so skip this test. - return; - } - // Create a temporary workspace with two writable roots: one containing // a top-level .git directory and one without it. let tmp = TempDir::new().expect("tempdir"); @@ -199,6 +227,12 @@ mod tests { format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()), ]; + expected_args.extend( + macos_dir_params() + .into_iter() + .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), + ); + expected_args.extend(vec![ "--".to_string(), "/bin/echo".to_string(), @@ -210,11 +244,6 @@ mod tests { #[test] fn create_seatbelt_args_for_cwd_as_git_repo() { - if cfg!(target_os = "windows") { - // /tmp does not exist on Windows, so skip this test. - return; - } - // Create a temporary workspace with two writable roots: one containing // a top-level .git directory and one without it. let tmp = TempDir::new().expect("tempdir"); @@ -292,6 +321,12 @@ mod tests { expected_args.push(format!("-DWRITABLE_ROOT_2={p}")); } + expected_args.extend( + macos_dir_params() + .into_iter() + .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())), + ); + expected_args.extend(vec![ "--".to_string(), "/bin/echo".to_string(), diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/core/src/seatbelt_network_policy.sbpl new file mode 100644 index 0000000000..2a72f95fd3 --- /dev/null +++ b/codex-rs/core/src/seatbelt_network_policy.sbpl @@ -0,0 +1,30 @@ +; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl +; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4 + +(allow network-outbound) +(allow network-inbound) +(allow system-socket) + +(allow mach-lookup + ; Used to look up the _CS_DARWIN_USER_CACHE_DIR in the sandbox. + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.system.opendirectoryd.membership") + + ; Communicate with the security server for TLS certificate information. + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd.agent") + + ; Read network configuration. + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) + +(allow sysctl-read + (sysctl-name-regex #"^net.routetable") +) + +(allow file-write* + (subpath (param "DARWIN_USER_CACHE_DIR")) +) From 1b8cc8b62513edafa3ec7b40e63b56ab50f44d4a Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 6 Nov 2025 14:13:24 -0800 Subject: [PATCH 055/122] [App Server] Add more session metadata to listConversations (#6337) This unlocks a few new product experience for app server consumers --- .../app-server-protocol/src/protocol/v1.rs | 13 +++ .../app-server/src/codex_message_processor.rs | 84 +++++++++++++++---- 2 files changed, 81 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index ea77e09a70..d518abc1bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -11,6 +11,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; use schemars::JsonSchema; use serde::Deserialize; @@ -113,6 +114,18 @@ pub struct ConversationSummary { pub preview: String, pub timestamp: Option, pub model_provider: String, + pub cwd: PathBuf, + pub cli_version: String, + pub source: SessionSource, + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct ConversationGitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ddf777de4c..ebfaed58c1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; @@ -130,8 +131,10 @@ use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_utils_json_to_toml::json_to_toml; @@ -1510,7 +1513,18 @@ impl CodexMessageProcessor { let items = page .items .into_iter() - .filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider)) + .filter_map(|it| { + let session_meta_line = it.head.first().and_then(|first| { + serde_json::from_value::(first.clone()).ok() + })?; + extract_conversation_summary( + it.path, + &it.head, + &session_meta_line.meta, + session_meta_line.git.as_ref(), + fallback_provider.as_str(), + ) + }) .collect::>(); // Encode next_cursor as a plain string @@ -2671,16 +2685,25 @@ async fn read_summary_from_rollout( ))); }; - let session_meta = serde_json::from_value::(first.clone()).map_err(|_| { - IoError::other(format!( - "rollout at {} does not start with session metadata", - path.display() - )) - })?; + let session_meta_line = + serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + let SessionMetaLine { + meta: session_meta, + git, + } = session_meta_line; - if let Some(summary) = - extract_conversation_summary(path.to_path_buf(), &head, fallback_provider) - { + if let Some(summary) = extract_conversation_summary( + path.to_path_buf(), + &head, + &session_meta, + git.as_ref(), + fallback_provider, + ) { return Ok(summary); } @@ -2691,7 +2714,9 @@ async fn read_summary_from_rollout( }; let model_provider = session_meta .model_provider + .clone() .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.as_ref().map(map_git_info); Ok(ConversationSummary { conversation_id: session_meta.id, @@ -2699,19 +2724,20 @@ async fn read_summary_from_rollout( path: path.to_path_buf(), preview: String::new(), model_provider, + cwd: session_meta.cwd, + cli_version: session_meta.cli_version, + source: session_meta.source, + git_info, }) } fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], + session_meta: &SessionMeta, + git: Option<&GitInfo>, fallback_provider: &str, ) -> Option { - let session_meta = match head.first() { - Some(first_line) => serde_json::from_value::(first_line.clone()).ok()?, - None => return None, - }; - let preview = head .iter() .filter_map(|value| serde_json::from_value::(value.clone()).ok()) @@ -2733,7 +2759,9 @@ fn extract_conversation_summary( let conversation_id = session_meta.id; let model_provider = session_meta .model_provider + .clone() .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.map(map_git_info); Some(ConversationSummary { conversation_id, @@ -2741,13 +2769,26 @@ fn extract_conversation_summary( path, preview: preview.to_string(), model_provider, + cwd: session_meta.cwd.clone(), + cli_version: session_meta.cli_version.clone(), + source: session_meta.source.clone(), + git_info, }) } +fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo { + ConversationGitInfo { + sha: git_info.commit_hash.clone(), + branch: git_info.branch.clone(), + origin_url: git_info.repository_url.clone(), + } +} + #[cfg(test)] mod tests { use super::*; use anyhow::Result; + use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -2786,8 +2827,11 @@ mod tests { }), ]; + let session_meta = serde_json::from_value::(head[0].clone())?; + let summary = - extract_conversation_summary(path.clone(), &head, "test-provider").expect("summary"); + extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider") + .expect("summary"); let expected = ConversationSummary { conversation_id, @@ -2795,6 +2839,10 @@ mod tests { path, preview: "Count to 5".to_string(), model_provider: "test-provider".to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: SessionSource::VSCode, + git_info: None, }; assert_eq!(summary, expected); @@ -2839,6 +2887,10 @@ mod tests { path: path.clone(), preview: String::new(), model_provider: "fallback".to_string(), + cwd: PathBuf::new(), + cli_version: String::new(), + source: SessionSource::VSCode, + git_info: None, }; assert_eq!(summary, expected); From b5349202e96d75b4b84d71e6953a14bcd95f2478 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 6 Nov 2025 14:14:27 -0800 Subject: [PATCH 056/122] Freeform unified exec output formatting (#6233) --- .../core/src/tools/handlers/unified_exec.rs | 57 ++-- codex-rs/core/tests/suite/unified_exec.rs | 268 ++++++++++++++---- 2 files changed, 230 insertions(+), 95 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 32ace6c959..593925a726 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,8 +1,5 @@ -use std::time::Duration; - use async_trait::async_trait; use serde::Deserialize; -use serde::Serialize; use crate::function_tool::FunctionCallError; use crate::protocol::EventMsg; @@ -163,11 +160,7 @@ impl ToolHandler for UnifiedExecHandler { .await; } - let content = serialize_response(&response).map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to serialize unified exec output: {err:?}" - )) - })?; + let content = format_response(&response); Ok(ToolOutput::Function { content, @@ -177,32 +170,30 @@ impl ToolHandler for UnifiedExecHandler { } } -#[derive(Serialize)] -struct SerializedUnifiedExecResponse<'a> { - chunk_id: &'a str, - wall_time_seconds: f64, - output: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - original_token_count: Option, -} +fn format_response(response: &UnifiedExecResponse) -> String { + let mut sections = Vec::new(); -fn serialize_response(response: &UnifiedExecResponse) -> Result { - let payload = SerializedUnifiedExecResponse { - chunk_id: &response.chunk_id, - wall_time_seconds: duration_to_seconds(response.wall_time), - output: &response.output, - session_id: response.session_id, - exit_code: response.exit_code, - original_token_count: response.original_token_count, - }; + if !response.chunk_id.is_empty() { + sections.push(format!("Chunk ID: {}", response.chunk_id)); + } - serde_json::to_string(&payload) -} + let wall_time_seconds = response.wall_time.as_secs_f64(); + sections.push(format!("Wall time: {wall_time_seconds:.4} seconds")); -fn duration_to_seconds(duration: Duration) -> f64 { - duration.as_secs_f64() + if let Some(exit_code) = response.exit_code { + sections.push(format!("Process exited with code {exit_code}")); + } + + if let Some(session_id) = response.session_id { + sections.push(format!("Process running with session ID {session_id}")); + } + + if let Some(original_token_count) = response.original_token_count { + sections.push(format!("Original token count: {original_token_count}")); + } + + sections.push("Output:".to_string()); + sections.push(response.output.clone()); + + sections.join("\n") } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 0e68ec7585..3db8050a5b 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,7 +1,8 @@ #![cfg(not(target_os = "windows"))] - use std::collections::HashMap; +use std::sync::OnceLock; +use anyhow::Context; use anyhow::Result; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; @@ -10,6 +11,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::user_input::UserInput; +use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -24,6 +26,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; +use regex_lite::Regex; use serde_json::Value; use serde_json::json; @@ -35,7 +38,95 @@ fn extract_output_text(item: &Value) -> Option<&str> { }) } -fn collect_tool_outputs(bodies: &[Value]) -> Result> { +#[derive(Debug)] +struct ParsedUnifiedExecOutput { + chunk_id: Option, + wall_time_seconds: f64, + session_id: Option, + exit_code: Option, + original_token_count: Option, + output: String, +} + +#[allow(clippy::expect_used)] +fn parse_unified_exec_output(raw: &str) -> Result { + static OUTPUT_REGEX: OnceLock = OnceLock::new(); + let regex = OUTPUT_REGEX.get_or_init(|| { + Regex::new(concat!( + r#"(?s)^(?:Total output lines: \d+\n\n)?"#, + r#"(?:Chunk ID: (?P[^\n]+)\n)?"#, + r#"Wall time: (?P-?\d+(?:\.\d+)?) seconds\n"#, + r#"(?:Process exited with code (?P-?\d+)\n)?"#, + r#"(?:Process running with session ID (?P-?\d+)\n)?"#, + r#"(?:Original token count: (?P\d+)\n)?"#, + r#"Output:\n?(?P.*)$"#, + )) + .expect("valid unified exec output regex") + }); + + let cleaned = raw.trim_matches('\r'); + let captures = regex + .captures(cleaned) + .ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output"))?; + + let chunk_id = captures + .name("chunk_id") + .map(|value| value.as_str().to_string()); + + let wall_time_seconds = captures + .name("wall_time") + .expect("wall_time group present") + .as_str() + .parse::() + .context("failed to parse wall time seconds")?; + + let exit_code = captures + .name("exit_code") + .map(|value| { + value + .as_str() + .parse::() + .context("failed to parse exit code from unified exec output") + }) + .transpose()?; + + let session_id = captures + .name("session_id") + .map(|value| { + value + .as_str() + .parse::() + .context("failed to parse session id from unified exec output") + }) + .transpose()?; + + let original_token_count = captures + .name("original_token_count") + .map(|value| { + value + .as_str() + .parse::() + .context("failed to parse original token count from unified exec output") + }) + .transpose()?; + + let output = captures + .name("output") + .expect("output group present") + .as_str() + .to_string(); + + Ok(ParsedUnifiedExecOutput { + chunk_id, + wall_time_seconds, + session_id, + exit_code, + original_token_count, + output, + }) +} + +fn collect_tool_outputs(bodies: &[Value]) -> Result> { let mut outputs = HashMap::new(); for body in bodies { if let Some(items) = body.get("input").and_then(Value::as_array) { @@ -50,8 +141,8 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result> { if trimmed.is_empty() { continue; } - let parsed: Value = serde_json::from_str(trimmed).map_err(|err| { - anyhow::anyhow!("failed to parse tool output content {trimmed:?}: {err}") + let parsed = parse_unified_exec_output(content).with_context(|| { + format!("failed to parse unified exec output for {call_id}") })?; outputs.insert(call_id.to_string(), parsed); } @@ -556,51 +647,38 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { .get(call_id) .expect("missing exec_command metadata output"); - let chunk_id = metadata - .get("chunk_id") - .and_then(Value::as_str) - .expect("missing chunk_id"); + let chunk_id = metadata.chunk_id.as_ref().expect("missing chunk_id"); assert_eq!(chunk_id.len(), 6, "chunk id should be 6 hex characters"); assert!( chunk_id.chars().all(|c| c.is_ascii_hexdigit()), "chunk id should be hexadecimal: {chunk_id}" ); - let wall_time = metadata - .get("wall_time_seconds") - .and_then(Value::as_f64) - .unwrap_or_default(); + let wall_time = metadata.wall_time_seconds; assert!( wall_time >= 0.0, "wall_time_seconds should be non-negative, got {wall_time}" ); assert!( - metadata.get("session_id").is_none(), + metadata.session_id.is_none(), "exec_command for a completed process should not include session_id" ); - let exit_code = metadata - .get("exit_code") - .and_then(Value::as_i64) - .expect("expected exit_code"); + let exit_code = metadata.exit_code.expect("expected exit_code"); assert_eq!(exit_code, 0, "expected successful exit"); - let output_text = metadata - .get("output") - .and_then(Value::as_str) - .expect("missing output text"); + let output_text = &metadata.output; assert!( output_text.contains("tokens truncated"), "expected truncation notice in output: {output_text:?}" ); let original_tokens = metadata - .get("original_token_count") - .and_then(Value::as_u64) - .expect("missing original_token_count"); + .original_token_count + .expect("missing original_token_count") as usize; assert!( - original_tokens as usize > 6, + original_tokens > 6, "original token count should exceed max_output_tokens" ); @@ -711,39 +789,34 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { .get(start_call_id) .expect("missing start output for exec_command"); let session_id = start_output - .get("session_id") - .and_then(Value::as_i64) + .session_id .expect("expected session id from exec_command"); assert!( session_id >= 0, "session_id should be non-negative, got {session_id}" ); assert!( - start_output.get("exit_code").is_none(), + start_output.exit_code.is_none(), "initial exec_command should not include exit_code while session is running" ); let send_output = outputs .get(send_call_id) .expect("missing write_stdin echo output"); - let echoed = send_output - .get("output") - .and_then(Value::as_str) - .unwrap_or_default(); + let echoed = send_output.output.as_str(); assert!( echoed.contains("hello unified exec"), "expected echoed output from cat, got {echoed:?}" ); let echoed_session = send_output - .get("session_id") - .and_then(Value::as_i64) + .session_id .expect("write_stdin should return session id while process is running"); assert_eq!( echoed_session, session_id, "write_stdin should reuse existing session id" ); assert!( - send_output.get("exit_code").is_none(), + send_output.exit_code.is_none(), "write_stdin should not include exit_code while process is running" ); @@ -751,18 +824,17 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { .get(exit_call_id) .expect("missing exit metadata output"); assert!( - exit_output.get("session_id").is_none(), + exit_output.session_id.is_none(), "session_id should be omitted once the process exits" ); let exit_code = exit_output - .get("exit_code") - .and_then(Value::as_i64) + .exit_code .expect("expected exit_code after sending EOF"); assert_eq!(exit_code, 0, "cat should exit cleanly after EOF"); let exit_chunk = exit_output - .get("chunk_id") - .and_then(Value::as_str) + .chunk_id + .as_ref() .expect("missing chunk id for exit output"); assert!( exit_chunk.chars().all(|c| c.is_ascii_hexdigit()), @@ -964,26 +1036,18 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let start_output = outputs .get(first_call_id) .expect("missing first unified_exec output"); - let session_id = start_output["session_id"].as_i64().unwrap_or_default(); + let session_id = start_output.session_id.unwrap_or_default(); assert!( session_id >= 0, "expected session id in first unified_exec response" ); - assert!( - start_output["output"] - .as_str() - .unwrap_or_default() - .is_empty() - ); + assert!(start_output.output.is_empty()); let reuse_output = outputs .get(second_call_id) .expect("missing reused unified_exec output"); - assert_eq!( - reuse_output["session_id"].as_i64().unwrap_or_default(), - session_id - ); - let echoed = reuse_output["output"].as_str().unwrap_or_default(); + assert_eq!(reuse_output.session_id.unwrap_or_default(), session_id); + let echoed = reuse_output.output.as_str(); assert!( echoed.contains("hello unified exec"), "expected echoed output, got {echoed:?}" @@ -1100,7 +1164,7 @@ PY let start_output = outputs .get(first_call_id) .expect("missing initial unified_exec output"); - let session_id = start_output["session_id"].as_i64().unwrap_or_default(); + let session_id = start_output.session_id.unwrap_or_default(); assert!( session_id >= 0, "expected session id from initial unified_exec response" @@ -1109,7 +1173,7 @@ PY let poll_output = outputs .get(second_call_id) .expect("missing poll unified_exec output"); - let poll_text = poll_output["output"].as_str().unwrap_or_default(); + let poll_text = poll_output.output.as_str(); assert!( poll_text.contains("TAIL-MARKER"), "expected poll output to contain tail marker, got {poll_text:?}" @@ -1209,16 +1273,11 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let outputs = collect_tool_outputs(&bodies)?; let first_output = outputs.get(first_call_id).expect("missing timeout output"); - assert_eq!(first_output["session_id"], 0); - assert!( - first_output["output"] - .as_str() - .unwrap_or_default() - .is_empty() - ); + assert_eq!(first_output.session_id, Some(0)); + assert!(first_output.output.is_empty()); let poll_output = outputs.get(second_call_id).expect("missing poll output"); - let output_text = poll_output["output"].as_str().unwrap_or_default(); + let output_text = poll_output.output.as_str(); assert!( output_text.contains("ready"), "expected ready output, got {output_text:?}" @@ -1226,3 +1285,88 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_formats_large_output_summary() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let script = r#"python3 - <<'PY' +for i in range(300): + print(f"line-{i}") +PY +"#; + + let call_id = "uexec-large-output"; + let args = serde_json::json!({ + "cmd": script, + "yield_time_ms": 500, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "summarize large output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let large_output = outputs.get(call_id).expect("missing large output summary"); + + assert_regex_match( + concat!( + r"(?s)", + r"line-0.*?", + r"\[\.{3} omitted \d+ of \d+ lines \.{3}\].*?", + r"line-299", + ), + &large_output.output, + ); + + Ok(()) +} From 65d53fd4b17d979bc7047f62b1aa06927f60b7ca Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 6 Nov 2025 14:45:51 -0800 Subject: [PATCH 057/122] Make generate_ts prettier output warn-only (#6342) Before, every file would be outputted with the time prettier spent formatting it. This made downstream scripts way too noisy. --- codex-rs/protocol-ts/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 224fdd2d07..c3180d9b93 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -46,6 +46,8 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { { let status = Command::new(prettier_bin) .arg("--write") + .arg("--log-level") + .arg("warn") .args(ts_files.iter().map(|p| p.as_os_str())) .status() .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; From 1bd2d7a659d1e1a4bb987d6d61c2cc1ebf752a40 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:50:07 -0800 Subject: [PATCH 058/122] tui: fix backtracking past /status (#6335) Fixes https://github.com/openai/codex/issues/4722 Supersedes https://github.com/openai/codex/pull/5058 Ideally we'd have a clearer way of separating history per-session than by detecting a specific history cell type, but this is a fairly minimal fix for now. --- codex-rs/tui/src/app_backtrack.rs | 6 +++--- codex-rs/tui/src/history_cell.rs | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index e2ded3d3ef..b161867e44 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::app::App; -use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::SessionInfoCell; use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; @@ -394,13 +394,13 @@ fn nth_user_position( fn user_positions_iter( cells: &[Arc], ) -> impl Iterator + '_ { - let header_type = TypeId::of::(); + let session_start_type = TypeId::of::(); let user_type = TypeId::of::(); let type_of = |cell: &Arc| cell.as_any().type_id(); let start = cells .iter() - .rposition(|cell| type_of(cell) == header_type) + .rposition(|cell| type_of(cell) == session_start_type) .map_or(0, |idx| idx + 1); cells diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 27f8220ac8..875bb9e2bb 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -559,11 +559,28 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } +#[derive(Debug)] +pub struct SessionInfoCell(CompositeHistoryCell); + +impl HistoryCell for SessionInfoCell { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn desired_height(&self, width: u16) -> u16 { + self.0.desired_height(width) + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.0.transcript_lines(width) + } +} + pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, is_first_event: bool, -) -> CompositeHistoryCell { +) -> SessionInfoCell { let SessionConfiguredEvent { model, reasoning_effort, @@ -573,7 +590,7 @@ pub(crate) fn new_session_info( initial_messages: _, rollout_path: _, } = event; - if is_first_event { + SessionInfoCell(if is_first_event { // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( model, @@ -632,7 +649,7 @@ pub(crate) fn new_session_info( CompositeHistoryCell { parts: vec![Box::new(PlainHistoryCell { lines })], } - } + }) } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { From e30f65118d8adb56bb4243dd3ac3497e25f86516 Mon Sep 17 00:00:00 2001 From: Ejaz Ahmed <47801736+ejazahm3d@users.noreply.github.com> Date: Fri, 7 Nov 2025 03:58:18 +0500 Subject: [PATCH 059/122] feat: Enable CTRL-n and CTRL-p for navigating slash commands, files, history (#1994) Adds CTRL-n and CTRL-p navigation for slash commands, files, and history. Closes #1992 Co-authored-by: Eric Traut --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 61539b6ed3..446563a192 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -410,6 +410,11 @@ impl ChatComposer { match key_event { KeyEvent { code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. } => { popup.move_up(); (InputResult::None, true) @@ -417,6 +422,11 @@ impl ChatComposer { KeyEvent { code: KeyCode::Down, .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. } => { popup.move_down(); (InputResult::None, true) @@ -584,6 +594,11 @@ impl ChatComposer { match key_event { KeyEvent { code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. } => { popup.move_up(); (InputResult::None, true) @@ -591,6 +606,11 @@ impl ChatComposer { KeyEvent { code: KeyCode::Down, .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. } => { popup.move_down(); (InputResult::None, true) @@ -870,6 +890,11 @@ impl ChatComposer { KeyEvent { code: KeyCode::Up | KeyCode::Down, .. + } + | KeyEvent { + code: KeyCode::Char('p') | KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. } => { if self .history @@ -878,6 +903,8 @@ impl ChatComposer { let replace_text = match key_event.code { KeyCode::Up => self.history.navigate_up(&self.app_event_tx), KeyCode::Down => self.history.navigate_down(&self.app_event_tx), + KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), + KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), _ => unreachable!(), }; if let Some(text) = replace_text { From 0c647bc56690ab2d727564e0c47374f2b9e21e54 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 6 Nov 2025 17:12:01 -0600 Subject: [PATCH 060/122] Don't retry "insufficient_quota" errors (#6340) This PR makes an "insufficient quota" error fatal so we don't attempt to retry it multiple times in the agent loop. We have multiple bug reports from users about intermittent retry behaviors, and this could explain some of them. With this change, we'll eliminate the retries and surface a clear error message. The PR is a nearly identical copy of [this PR](https://github.com/openai/codex/pull/4837) contributed by @abimaelmartell. The original PR has gone stale. Rather than wait for the contributor to resolve merge conflicts, I wanted to get this change in. --- codex-rs/core/src/client.rs | 43 ++++++++++++ codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/error.rs | 3 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/quota_exceeded.rs | 75 +++++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 codex-rs/core/tests/suite/quota_exceeded.rs diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8cd1bca564..3a0bcb9b54 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -447,6 +447,8 @@ impl ModelClient { return Err(StreamAttemptError::Fatal(codex_err)); } else if error.r#type.as_deref() == Some("usage_not_included") { return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded)); + } else if is_quota_exceeded_error(&error) { + return Err(StreamAttemptError::Fatal(CodexErr::QuotaExceeded)); } } } @@ -844,6 +846,8 @@ async fn process_sse( Ok(error) => { if is_context_window_error(&error) { response_error = Some(CodexErr::ContextWindowExceeded); + } else if is_quota_exceeded_error(&error) { + response_error = Some(CodexErr::QuotaExceeded); } else { let delay = try_parse_retry_after(&error); let message = error.message.clone().unwrap_or_default(); @@ -975,6 +979,10 @@ fn is_context_window_error(error: &Error) -> bool { error.code.as_deref() == Some("context_length_exceeded") } +fn is_quota_exceeded_error(error: &Error) -> bool { + error.code.as_deref() == Some("insufficient_quota") +} + #[cfg(test)] mod tests { use super::*; @@ -1307,6 +1315,41 @@ mod tests { } } + #[tokio::test] + async fn quota_exceeded_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let provider = ModelProviderInfo { + name: "test".to_string(), + base_url: Some("https://test.com".to_string()), + env_key: Some("TEST_API_KEY".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(1000), + requires_openai_auth: false, + }; + + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(err @ CodexErr::QuotaExceeded) => { + assert_eq!(err.to_string(), CodexErr::QuotaExceeded.to_string()); + } + other => panic!("unexpected quota exceeded event: {other:?}"), + } + } + // ──────────────────────────── // Table-driven test from `main` // ──────────────────────────── diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 64cd00232e..70728ba607 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1928,6 +1928,7 @@ async fn run_turn( return Err(CodexErr::UsageLimitReached(e)); } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), + Err(e @ CodexErr::QuotaExceeded) => return Err(e), Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6ca8970e3f..10e936f1a5 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -109,6 +109,9 @@ pub enum CodexErr { #[error("{0}")] ConnectionFailed(ConnectionFailedError), + #[error("Quota exceeded. Check your plan and billing details.")] + QuotaExceeded, + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index bec4f942f8..a884167471 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -26,6 +26,7 @@ mod model_overrides; mod model_tools; mod otel; mod prompt_caching; +mod quota_exceeded; mod read_file; mod resume; mod review; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs new file mode 100644 index 0000000000..4cb634c128 --- /dev/null +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -0,0 +1,75 @@ +use std::time::Duration; + +use anyhow::Result; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_with_timeout; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn quota_exceeded_emits_single_error_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex(); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + json!({ + "type": "response.failed", + "response": { + "id": "resp-1", + "error": { + "code": "insufficient_quota", + "message": "You exceeded your current quota, please check your plan and billing details." + } + } + }), + ]), + ) + .await; + + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "quota?".into(), + }], + }) + .await + .unwrap(); + + let mut error_events = 0; + + loop { + let event = + wait_for_event_with_timeout(&test.codex, |_| true, Duration::from_secs(5)).await; + + match event { + EventMsg::Error(err) => { + error_events += 1; + assert_eq!( + err.message, + "Quota exceeded. Check your plan and billing details." + ); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + assert_eq!(error_events, 1, "expected exactly one Codex:Error event"); + + Ok(()) +} From c368c6aeea43717e3e05182dbc8a53eb85037ac4 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 6 Nov 2025 15:46:24 -0800 Subject: [PATCH 061/122] Remove shell tool when unified exec is enabled (#6345) Also drop streameable shell that's just an alias for unified exec. --- codex-rs/core/src/codex.rs | 3 +- codex-rs/core/src/config/mod.rs | 13 +- codex-rs/core/src/config/profile.rs | 1 - codex-rs/core/src/features.rs | 14 +-- codex-rs/core/src/features/legacy.rs | 11 -- codex-rs/core/src/tasks/review.rs | 6 +- codex-rs/core/src/tools/spec.rs | 111 +++++++++++++----- .../core/tests/suite/deprecation_notice.rs | 15 ++- codex-rs/core/tests/suite/model_tools.rs | 1 - docs/example-config.md | 4 - 10 files changed, 94 insertions(+), 85 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 70728ba607..fc6b44c621 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1643,8 +1643,7 @@ async fn spawn_review_thread( let mut review_features = config.features.clone(); review_features .disable(crate::features::Feature::WebSearchRequest) - .disable(crate::features::Feature::ViewImageTool) - .disable(crate::features::Feature::StreamableShell); + .disable(crate::features::Feature::ViewImageTool); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, features: &review_features, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1ee48d30bc..0dc9d12667 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -241,8 +241,6 @@ pub struct Config { /// When `true`, run a model-based assessment for commands denied by the sandbox. pub experimental_sandbox_command_assessment: bool, - pub use_experimental_streamable_shell_tool: bool, - /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -655,7 +653,6 @@ pub struct ConfigToml { /// Legacy, now use features pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, - pub experimental_use_exec_command_tool: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, @@ -999,7 +996,6 @@ impl Config { let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let tools_web_search_request = features.enabled(Feature::WebSearchRequest); - let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); let experimental_sandbox_command_assessment = @@ -1156,7 +1152,6 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, experimental_sandbox_command_assessment, - use_experimental_streamable_shell_tool, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, features, @@ -1715,7 +1710,6 @@ trust_level = "trusted" fn legacy_toggles_map_to_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { - experimental_use_exec_command_tool: Some(true), experimental_use_unified_exec_tool: Some(true), experimental_use_rmcp_client: Some(true), experimental_use_freeform_apply_patch: Some(true), @@ -1729,12 +1723,11 @@ trust_level = "trusted" )?; assert!(config.features.enabled(Feature::ApplyPatchFreeform)); - assert!(config.features.enabled(Feature::StreamableShell)); assert!(config.features.enabled(Feature::UnifiedExec)); assert!(config.features.enabled(Feature::RmcpClient)); assert!(config.include_apply_patch_tool); - assert!(config.use_experimental_streamable_shell_tool); + assert!(config.use_experimental_unified_exec_tool); assert!(config.use_experimental_use_rmcp_client); @@ -2902,7 +2895,6 @@ model_verbosity = "high" include_apply_patch_tool: false, tools_web_search_request: false, experimental_sandbox_command_assessment: false, - use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -2974,7 +2966,6 @@ model_verbosity = "high" include_apply_patch_tool: false, tools_web_search_request: false, experimental_sandbox_command_assessment: false, - use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3061,7 +3052,6 @@ model_verbosity = "high" include_apply_patch_tool: false, tools_web_search_request: false, experimental_sandbox_command_assessment: false, - use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3134,7 +3124,6 @@ model_verbosity = "high" include_apply_patch_tool: false, tools_web_search_request: false, experimental_sandbox_command_assessment: false, - use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index f7eb2dcedb..6d872546af 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -25,7 +25,6 @@ pub struct ConfigProfile { pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, - pub experimental_use_exec_command_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_sandbox_command_assessment: Option, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index f6cec4a5ea..e34128f362 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -29,8 +29,6 @@ pub enum Stage { pub enum Feature { /// Use the single unified PTY-backed exec tool. UnifiedExec, - /// Use the streamable exec-command/write-stdin tool pair. - StreamableShell, /// Enable experimental RMCP features such as OAuth login. RmcpClient, /// Include the freeform apply_patch tool. @@ -118,8 +116,9 @@ impl Features { self.enabled.contains(&f) } - pub fn enable(&mut self, f: Feature) { + pub fn enable(&mut self, f: Feature) -> &mut Self { self.enabled.insert(f); + self } pub fn disable(&mut self, f: Feature) -> &mut Self { @@ -178,7 +177,6 @@ impl Features { let base_legacy = LegacyFeatureToggles { experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, - experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search), @@ -197,7 +195,7 @@ impl Features { .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, - experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool, + experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool, experimental_use_rmcp_client: config_profile.experimental_use_rmcp_client, tools_web_search: config_profile.tools_web_search, @@ -252,12 +250,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::StreamableShell, - key: "streamable_shell", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::RmcpClient, key: "rmcp_client", diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index de3c007bac..4d59f2a9a3 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -17,10 +17,6 @@ const ALIASES: &[Alias] = &[ legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, }, - Alias { - legacy_key: "experimental_use_exec_command_tool", - feature: Feature::StreamableShell, - }, Alias { legacy_key: "experimental_use_rmcp_client", feature: Feature::RmcpClient, @@ -54,7 +50,6 @@ pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_use_exec_command_tool: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub tools_web_search: Option, @@ -81,12 +76,6 @@ impl LegacyFeatureToggles { self.experimental_use_freeform_apply_patch, "experimental_use_freeform_apply_patch", ); - set_if_some( - features, - Feature::StreamableShell, - self.experimental_use_exec_command_tool, - "experimental_use_exec_command_tool", - ); set_if_some( features, Feature::UnifiedExec, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 57258f4cd9..e0bb7d4e93 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -75,12 +75,12 @@ async fn start_review_conversation( // Avoid loading project docs; reviewer only needs findings sub_agent_config.project_doc_max_bytes = 0; // Carry over review-only feature restrictions so the delegate cannot - // re-enable blocked tools (web search, view image, streamable shell). + // re-enable blocked tools (web search, view image). sub_agent_config .features .disable(crate::features::Feature::WebSearchRequest) - .disable(crate::features::Feature::ViewImageTool) - .disable(crate::features::Feature::StreamableShell); + .disable(crate::features::Feature::ViewImageTool); + // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); (run_codex_conversation_one_shot( diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index eba9fd517c..8f4e6f35e7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; pub enum ConfigShellToolType { Default, Local, - Streamable, + UnifiedExec, } #[derive(Debug, Clone)] @@ -28,7 +28,6 @@ pub(crate) struct ToolsConfig { pub apply_patch_tool_type: Option, pub web_search_request: bool, pub include_view_image_tool: bool, - pub experimental_unified_exec_tool: bool, pub experimental_supported_tools: Vec, } @@ -43,14 +42,13 @@ impl ToolsConfig { model_family, features, } = params; - let use_streamable_shell_tool = features.enabled(Feature::StreamableShell); - let experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + let use_unified_exec_tool = features.enabled(Feature::UnifiedExec); let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_web_search_request = features.enabled(Feature::WebSearchRequest); let include_view_image_tool = features.enabled(Feature::ViewImageTool); - let shell_type = if use_streamable_shell_tool { - ConfigShellToolType::Streamable + let shell_type = if use_unified_exec_tool { + ConfigShellToolType::UnifiedExec } else if model_family.uses_local_shell_tool { ConfigShellToolType::Local } else { @@ -74,7 +72,6 @@ impl ToolsConfig { apply_patch_tool_type, web_search_request: include_web_search_request, include_view_image_tool, - experimental_unified_exec_tool, experimental_supported_tools: model_family.experimental_supported_tools.clone(), } } @@ -886,15 +883,6 @@ pub(crate) fn build_specs( let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); - let use_unified_exec = config.experimental_unified_exec_tool - || matches!(config.shell_type, ConfigShellToolType::Streamable); - - if use_unified_exec { - builder.push_spec(create_exec_command_tool()); - builder.push_spec(create_write_stdin_tool()); - builder.register_handler("exec_command", unified_exec_handler.clone()); - builder.register_handler("write_stdin", unified_exec_handler); - } match &config.shell_type { ConfigShellToolType::Default => { builder.push_spec(create_shell_tool()); @@ -902,8 +890,11 @@ pub(crate) fn build_specs( ConfigShellToolType::Local => { builder.push_spec(ToolSpec::LocalShell {}); } - ConfigShellToolType::Streamable => { - // Already handled by use_unified_exec. + ConfigShellToolType::UnifiedExec => { + builder.push_spec(create_exec_command_tool()); + builder.push_spec(create_write_stdin_tool()); + builder.register_handler("exec_command", unified_exec_handler.clone()); + builder.register_handler("write_stdin", unified_exec_handler); } } @@ -1045,7 +1036,7 @@ mod tests { match config.shell_type { ConfigShellToolType::Default => Some("shell"), ConfigShellToolType::Local => Some("local_shell"), - ConfigShellToolType::Streamable => None, + ConfigShellToolType::UnifiedExec => None, } } @@ -1095,7 +1086,7 @@ mod tests { } #[test] - fn test_full_toolset_specs_for_gpt5_codex() { + fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { let model_family = find_family_for_model("gpt-5-codex") .expect("gpt-5-codex should be a valid model family"); let mut features = Features::with_defaults(); @@ -1129,7 +1120,6 @@ mod tests { for spec in [ create_exec_command_tool(), create_write_stdin_tool(), - create_shell_tool(), create_list_mcp_resources_tool(), create_list_mcp_resource_templates_tool(), create_read_mcp_resource_tool(), @@ -1156,32 +1146,89 @@ mod tests { } } - #[test] - fn test_build_specs_contains_expected_basics() { - let model_family = find_family_for_model("codex-mini-latest") - .expect("codex-mini-latest should be a valid model family"); - let mut features = Features::with_defaults(); - features.enable(Feature::WebSearchRequest); - features.enable(Feature::UnifiedExec); + fn assert_model_tools(model_family: &str, features: &Features, expected_tools: &[&str]) { + let model_family = find_family_for_model(model_family) + .unwrap_or_else(|| panic!("{model_family} should be a valid model family")); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features: &features, + features, }); let (tools, _) = build_specs(&config, Some(HashMap::new())).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); - assert_eq!( - &tool_names, + assert_eq!(&tool_names, &expected_tools,); + } + + #[test] + fn test_build_specs_gpt5_codex_default() { + assert_model_tools( + "gpt-5-codex", + &Features::with_defaults(), + &[ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "apply_patch", + "view_image", + ], + ); + } + + #[test] + fn test_build_specs_gpt5_codex_unified_exec_web_search() { + assert_model_tools( + "gpt-5-codex", + Features::with_defaults() + .enable(Feature::UnifiedExec) + .enable(Feature::WebSearchRequest), &[ "exec_command", "write_stdin", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "apply_patch", + "web_search", + "view_image", + ], + ); + } + + #[test] + fn test_codex_mini_defaults() { + assert_model_tools( + "codex-mini-latest", + &Features::with_defaults(), + &[ "local_shell", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", "update_plan", + "view_image", + ], + ); + } + + #[test] + fn test_codex_mini_unified_exec_web_search() { + assert_model_tools( + "codex-mini-latest", + Features::with_defaults() + .enable(Feature::UnifiedExec) + .enable(Feature::WebSearchRequest), + &[ + "exec_command", + "write_stdin", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", "web_search", "view_image", - ] + ], ); } diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 6842bd4d53..4e240f0a07 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -18,12 +18,11 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.features.enable(Feature::StreamableShell); - config.features.record_legacy_usage_force( - "experimental_use_exec_command_tool", - Feature::StreamableShell, - ); - config.use_experimental_streamable_shell_tool = true; + config.features.enable(Feature::UnifiedExec); + config + .features + .record_legacy_usage_force("use_experimental_unified_exec_tool", Feature::UnifiedExec); + config.use_experimental_unified_exec_tool = true; }); let TestCodex { codex, .. } = builder.build(&server).await?; @@ -37,13 +36,13 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let DeprecationNoticeEvent { summary, details } = notice; assert_eq!( summary, - "`experimental_use_exec_command_tool` is deprecated. Use `streamable_shell` instead." + "`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead." .to_string(), ); assert_eq!( details.as_deref(), Some( - "Enable it with `--enable streamable_shell` or `[features].streamable_shell` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." + "Enable it with `--enable unified_exec` or `[features].unified_exec` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." ), ); diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 2a04b88a16..c7622f8b72 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -60,7 +60,6 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { config.features.disable(Feature::ApplyPatchFreeform); config.features.disable(Feature::ViewImageTool); config.features.disable(Feature::WebSearchRequest); - config.features.disable(Feature::StreamableShell); config.features.disable(Feature::UnifiedExec); let conversation_manager = diff --git a/docs/example-config.md b/docs/example-config.md index 531a385600..524cbbe02c 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -221,9 +221,6 @@ enable_experimental_windows_sandbox = false # Experimental toggles (legacy; prefer [features]) ################################################################################ -# Use experimental exec command tool (streamable shell). Default: false -experimental_use_exec_command_tool = false - # Use experimental unified exec tool. Default: false experimental_use_unified_exec_tool = false @@ -328,7 +325,6 @@ mcp_oauth_credentials_store = "auto" # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false # experimental_use_unified_exec_tool = false -# experimental_use_exec_command_tool = false # experimental_use_rmcp_client = false # experimental_use_freeform_apply_patch = false # experimental_sandbox_command_assessment = false From 039a4b070e175f94f5704dc6b297e3d76b0cb0cb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 6 Nov 2025 18:02:12 -0600 Subject: [PATCH 062/122] Updated the AI labeler rules to match the most recent issue tracker labels (#6347) This PR updates the AI prompt used for the workflow that adds automated labels to incoming issues. I've been updating and refining the list of labels as I work through the issue backlog, and the old prompt was becoming somewhat outdated. --- .github/workflows/issue-labeler.yml | 37 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 82ffb83915..39f9d47f14 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -26,21 +26,36 @@ jobs: prompt: | You are an assistant that reviews GitHub issues for the repository. - Your job is to choose the most appropriate existing labels for the issue described later in this prompt. + Your job is to choose the most appropriate labels for the issue described later in this prompt. Follow these rules: - - Only pick labels out of the list below. - - Prefer a small set of precise labels over many broad ones. - Labels to apply: + - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. - 3. extension — VS Code (or other IDE) extension-specific issues. - 4. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). - 5. mcp — Topics involving Model Context Protocol servers/clients. - 6. codex-web — Issues targeting the Codex web UI/Cloud experience. - 8. azure — Problems or requests tied to Azure OpenAI deployments. - 9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). - 10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). + + - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. + 1. CLI — the Codex command line interface. + 2. extension — VS Code (or other IDE) extension-specific issues. + 3. codex-web — Issues targeting the Codex web UI/Cloud experience. + 4. github-action — Issues with the Codex GitHub action. + 5. iOS — Issues with the Codex iOS app. + + - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. + 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). + 2. mcp — Topics involving Model Context Protocol servers/clients. + 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. + 4. azure — Problems or requests tied to Azure OpenAI deployments. + 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 6. code-review — Issues related to the code review feature or functionality. + 7. auth - Problems related to authentication, login, or access tokens. + 8. codex-exec - Problems related to the "codex exec" command or functionality. + 9. context-management - Problems related to compaction, context windows, or available context reporting. + 10. custom-model - Problems that involve using custom model providers, local models, or OSS models. + 11. rate-limits - Problems related to token limits, rate limits, or token usage reporting. + 12. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. + 13. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. + 14. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. Issue number: ${{ github.event.issue.number }} From f8b30af6dc275b3e64de5f1987e6cafe604cb72a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 6 Nov 2025 16:14:43 -0800 Subject: [PATCH 063/122] Prefer `wait_for_event` over `wait_for_event_with_timeout`. (#6346) No need to specify the timeout in most cases. --- AGENTS.md | 1 + codex-rs/core/tests/suite/quota_exceeded.rs | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de6ef8b9da..7960ebba9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ If you don’t have the tool: - Use `ResponseMock::single_request()` when a test should only issue one POST, or `ResponseMock::requests()` to inspect every captured `ResponsesRequest`. - `ResponsesRequest` exposes helpers (`body_json`, `input`, `function_call_output`, `custom_tool_call_output`, `call_output`, `header`, `path`, `query_param`) so assertions can target structured payloads instead of manual JSON digging. - Build SSE payloads with the provided `ev_*` constructors and the `sse(...)`. +- Prefer `wait_for_event` over `wait_for_event_with_timeout`. - Typical pattern: diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 4cb634c128..0156c8d116 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use anyhow::Result; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -10,7 +8,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; @@ -53,8 +51,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { let mut error_events = 0; loop { - let event = - wait_for_event_with_timeout(&test.codex, |_| true, Duration::from_secs(5)).await; + let event = wait_for_event(&test.codex, |_| true).await; match event { EventMsg::Error(err) => { From 316352be943cfe740850fb5e6dd543911a755bc6 Mon Sep 17 00:00:00 2001 From: Shane Vitarana Date: Thu, 6 Nov 2025 20:02:09 -0500 Subject: [PATCH 064/122] Fix apply_patch rename move path resolution (#5486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/openai/codex/issues/5485. Fixed rename hunks so `apply_patch` resolves the destination path using the verifier’s effective cwd, ensuring patches that run under `cd && apply_patch` stay inside the worktree. Added a regression test (`test_apply_patch_resolves_move_path_with_effective_cwd`) that reproduced the old behavior (dest path resolved in the main repo) and now passes. Related to https://github.com/openai/codex/issues/5483. Co-authored-by: Eric Traut --- codex-rs/apply-patch/src/lib.rs | 49 ++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 3d0052ed03..ac2f409791 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -288,7 +288,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp path, ApplyPatchFileChange::Update { unified_diff, - move_path: move_path.map(|p| cwd.join(p)), + move_path: move_path.map(|p| effective_cwd.join(p)), new_content: contents, }, ); @@ -1603,6 +1603,53 @@ g ); } + #[test] + fn test_apply_patch_resolves_move_path_with_effective_cwd() { + let session_dir = tempdir().unwrap(); + let worktree_rel = "alt"; + let worktree_dir = session_dir.path().join(worktree_rel); + fs::create_dir_all(&worktree_dir).unwrap(); + + let source_name = "old.txt"; + let dest_name = "renamed.txt"; + let source_path = worktree_dir.join(source_name); + fs::write(&source_path, "before\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {source_name} +*** Move to: {dest_name} +@@ +-before ++after"# + )); + + let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); + let argv = vec!["bash".into(), "-lc".into(), shell_script]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let action = match result { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected verified body, got {other:?}"), + }; + + assert_eq!(action.cwd, worktree_dir); + + let change = action + .changes() + .get(&worktree_dir.join(source_name)) + .expect("source file change present"); + + match change { + ApplyPatchFileChange::Update { move_path, .. } => { + assert_eq!( + move_path.as_deref(), + Some(worktree_dir.join(dest_name).as_path()) + ); + } + other => panic!("expected update change, got {other:?}"), + } + } + #[test] fn test_apply_patch_fails_on_write_error() { let dir = tempdir().unwrap(); From e8905f6d204ed634c4d746eadd4a8cd173ee0afa Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 6 Nov 2025 18:11:11 -0800 Subject: [PATCH 065/122] Prefer `wait_for_event` over `wait_for_event_with_timeout` (#6349) --- codex-rs/core/tests/suite/abort_tasks.rs | 41 +--- codex-rs/core/tests/suite/approvals.rs | 50 ++--- codex-rs/core/tests/suite/client.rs | 29 ++- codex-rs/core/tests/suite/otel.rs | 185 +++--------------- codex-rs/core/tests/suite/review.rs | 85 ++++---- codex-rs/core/tests/suite/rmcp_client.rs | 49 ++--- .../suite/stream_error_allows_next_turn.rs | 25 +-- .../core/tests/suite/stream_no_completed.rs | 11 +- codex-rs/core/tests/suite/unified_exec.rs | 5 +- 9 files changed, 124 insertions(+), 356 deletions(-) diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 6c03739593..1774619843 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -13,7 +13,7 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_event; use regex_lite::Regex; use serde_json::json; @@ -42,8 +42,6 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { let codex = test_codex().build(&server).await.unwrap().codex; - let wait_timeout = Duration::from_secs(5); - // Kick off a turn that triggers the function call. codex .submit(Op::UserInput { @@ -55,22 +53,12 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { .unwrap(); // Wait until the exec begins to avoid a race, then interrupt. - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecCommandBegin(_)), - wait_timeout, - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; codex.submit(Op::Interrupt).await.unwrap(); // Expect TurnAborted soon after. - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TurnAborted(_)), - wait_timeout, - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; } /// After an interrupt we expect the next request to the model to include both @@ -107,8 +95,6 @@ async fn interrupt_tool_records_history_entries() { let fixture = test_codex().build(&server).await.unwrap(); let codex = Arc::clone(&fixture.codex); - let wait_timeout = Duration::from_millis(100); - codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -118,22 +104,12 @@ async fn interrupt_tool_records_history_entries() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecCommandBegin(_)), - wait_timeout, - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; tokio::time::sleep(Duration::from_secs_f32(0.1)).await; codex.submit(Op::Interrupt).await.unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TurnAborted(_)), - wait_timeout, - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; codex .submit(Op::UserInput { @@ -144,12 +120,7 @@ async fn interrupt_tool_records_history_entries() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - wait_timeout, - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; let requests = response_mock.requests(); assert!( diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index cbb5898b4f..0f54713b97 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -23,14 +23,12 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::env; use std::fs; use std::path::PathBuf; -use std::time::Duration; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -423,16 +421,12 @@ async fn expect_exec_approval( test: &TestCodex, expected_command: &[String], ) -> ExecApprovalRequestEvent { - let event = wait_for_event_with_timeout( - &test.codex, - |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) - ) - }, - Duration::from_secs(5), - ) + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }) .await; match event { @@ -449,16 +443,12 @@ async fn expect_patch_approval( test: &TestCodex, expected_call_id: &str, ) -> ApplyPatchApprovalRequestEvent { - let event = wait_for_event_with_timeout( - &test.codex, - |event| { - matches!( - event, - EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_) - ) - }, - Duration::from_secs(5), - ) + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }) .await; match event { @@ -472,16 +462,12 @@ async fn expect_patch_approval( } async fn wait_for_completion_without_approval(test: &TestCodex) { - let event = wait_for_event_with_timeout( - &test.codex, - |event| { - matches!( - event, - EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) - ) - }, - Duration::from_secs(5), - ) + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_) + ) + }) .await; match event { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c2c5048dea..3e97d988ea 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -32,7 +32,6 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; use futures::StreamExt; use serde_json::json; use std::io::Write; @@ -1117,26 +1116,20 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }) .await?; - use std::time::Duration; - - let token_event = wait_for_event_with_timeout( - &codex, - |event| { - matches!( - event, - EventMsg::TokenCount(payload) - if payload.info.as_ref().is_some_and(|info| { - info.model_context_window == Some(info.total_token_usage.total_tokens) - && info.total_token_usage.total_tokens > 0 - }) - ) - }, - Duration::from_secs(5), - ) + let token_event = wait_for_event(&codex, |event| { + matches!( + event, + EventMsg::TokenCount(payload) + if payload.info.as_ref().is_some_and(|info| { + info.model_context_window == Some(info.total_token_usage.total_tokens) + && info.total_token_usage.total_tokens > 0 + }) + ) + }) .await; let EventMsg::TokenCount(token_payload) = token_event else { - unreachable!("wait_for_event_with_timeout returned unexpected event"); + unreachable!("wait_for_event returned unexpected event"); }; let info = token_payload diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index ec6d567cb6..92dd43fb55 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -14,8 +14,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_with_timeout; -use std::time::Duration; +use core_test_support::wait_for_event; use tracing_test::traced_test; use core_test_support::responses::ev_local_shell_call; @@ -38,12 +37,7 @@ async fn responses_api_emits_api_request_event() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -84,12 +78,7 @@ async fn process_sse_emits_tracing_for_output_item() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -128,12 +117,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -173,12 +157,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -230,12 +209,7 @@ async fn process_sse_failed_event_records_response_error_message() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -285,12 +259,7 @@ async fn process_sse_failed_event_logs_parse_error() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -335,12 +304,7 @@ async fn process_sse_failed_event_logs_missing_error() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -385,12 +349,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -440,12 +399,7 @@ async fn process_sse_emits_completed_telemetry() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(|lines: &[&str]| { lines @@ -500,12 +454,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(|lines: &[&str]| { let line = lines @@ -564,12 +513,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(|lines: &[&str]| { let line = lines @@ -638,12 +582,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(|lines: &[&str]| { let line = lines @@ -696,12 +635,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(|lines: &[&str]| { let line = lines @@ -794,12 +728,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "auto_config_call", @@ -840,12 +769,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -855,12 +779,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "user_approved_call", @@ -902,12 +821,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -917,12 +831,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "user_approved_session_call", @@ -964,12 +873,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -979,12 +883,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "sandbox_retry_call", @@ -1026,12 +925,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -1041,12 +935,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "user_denied_call", @@ -1088,12 +977,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -1103,12 +987,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "sandbox_session_call", @@ -1150,12 +1029,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::ExecApprovalRequest(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await; codex .submit(Op::ExecApproval { @@ -1165,12 +1039,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TokenCount(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; logs_assert(tool_decision_assertion( "sandbox_deny_call", diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 093fd99268..c56e529d6b 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,7 +23,6 @@ use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::sync::Arc; @@ -246,37 +245,31 @@ async fn review_filters_agent_message_related_events() { let mut saw_exited = false; // Drain until TaskComplete; assert filtered events never surface. - wait_for_event_with_timeout( - &codex, - |event| match event { - EventMsg::TaskComplete(_) => true, - EventMsg::EnteredReviewMode(_) => { - saw_entered = true; - false + wait_for_event(&codex, |event| match event { + EventMsg::TaskComplete(_) => true, + EventMsg::EnteredReviewMode(_) => { + saw_entered = true; + false + } + EventMsg::ExitedReviewMode(_) => { + saw_exited = true; + false + } + // The following must be filtered by review flow + EventMsg::AgentMessageContentDelta(_) => { + panic!("unexpected AgentMessageContentDelta surfaced during review") + } + EventMsg::AgentMessageDelta(_) => { + panic!("unexpected AgentMessageDelta surfaced during review") + } + EventMsg::ItemCompleted(ev) => match &ev.item { + codex_protocol::items::TurnItem::AgentMessage(_) => { + panic!("unexpected ItemCompleted for TurnItem::AgentMessage surfaced during review") } - EventMsg::ExitedReviewMode(_) => { - saw_exited = true; - false - } - // The following must be filtered by review flow - EventMsg::AgentMessageContentDelta(_) => { - panic!("unexpected AgentMessageContentDelta surfaced during review") - } - EventMsg::AgentMessageDelta(_) => { - panic!("unexpected AgentMessageDelta surfaced during review") - } - EventMsg::ItemCompleted(ev) => match &ev.item { - codex_protocol::items::TurnItem::AgentMessage(_) => { - panic!( - "unexpected ItemCompleted for TurnItem::AgentMessage surfaced during review" - ) - } - _ => false, - }, _ => false, }, - tokio::time::Duration::from_secs(5), - ) + _ => false, + }) .await; assert!(saw_entered && saw_exited, "missing review lifecycle events"); @@ -335,25 +328,21 @@ async fn review_does_not_emit_agent_message_on_structured_output() { // Drain events until TaskComplete; ensure none are AgentMessage. let mut saw_entered = false; let mut saw_exited = false; - wait_for_event_with_timeout( - &codex, - |event| match event { - EventMsg::TaskComplete(_) => true, - EventMsg::AgentMessage(_) => { - panic!("unexpected AgentMessage during review with structured output") - } - EventMsg::EnteredReviewMode(_) => { - saw_entered = true; - false - } - EventMsg::ExitedReviewMode(_) => { - saw_exited = true; - false - } - _ => false, - }, - tokio::time::Duration::from_secs(5), - ) + wait_for_event(&codex, |event| match event { + EventMsg::TaskComplete(_) => true, + EventMsg::AgentMessage(_) => { + panic!("unexpected AgentMessage during review with structured output") + } + EventMsg::EnteredReviewMode(_) => { + saw_entered = true; + false + } + EventMsg::ExitedReviewMode(_) => { + saw_exited = true; + false + } + _ => false, + }) .await; assert!(saw_entered && saw_exited, "missing review lifecycle events"); diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 672633ace5..343b506d4c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -25,7 +25,6 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; use escargot::CargoBuild; use mcp_types::ContentBlock; use serde_json::Value; @@ -125,11 +124,9 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { }) .await?; - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { @@ -268,11 +265,9 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { .await?; // Wait for tool begin/end and final completion. - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { unreachable!("begin"); @@ -465,11 +460,9 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { }) .await?; - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { unreachable!("begin"); @@ -609,11 +602,9 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { }) .await?; - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { @@ -762,11 +753,9 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { }) .await?; - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { @@ -947,11 +936,9 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { }) .await?; - let begin_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), - Duration::from_secs(10), - ) + let begin_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallBegin(_)) + }) .await; let EventMsg::McpToolCallBegin(begin) = begin_event else { diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index ba86f8c155..e6f8aa9560 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use codex_core::ModelProviderInfo; use codex_core::WireApi; use codex_core::protocol::EventMsg; @@ -9,7 +7,7 @@ use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_event; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -96,19 +94,9 @@ async fn continue_after_stream_error() { .unwrap(); // Expect an Error followed by TaskComplete so the session is released. - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::Error(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // 2) Second turn: now send another prompt that should succeed using the // mock server SSE stream. If the agent failed to clear the running task on @@ -122,10 +110,5 @@ async fn continue_after_stream_error() { .await .unwrap(); - wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::TaskComplete(_)), - Duration::from_secs(5), - ) - .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 550bb3f9c2..17a19e7c34 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -1,8 +1,6 @@ //! Verifies that the agent retries when the SSE stream terminates before //! delivering a `response.completed` event. -use std::time::Duration; - use codex_core::ModelProviderInfo; use codex_core::WireApi; use codex_core::protocol::EventMsg; @@ -13,7 +11,7 @@ use core_test_support::load_sse_fixture_with_id; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_event; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; @@ -103,10 +101,5 @@ async fn retries_on_early_close() { .unwrap(); // Wait until TaskComplete (should succeed after retry). - wait_for_event_with_timeout( - &codex, - |event| matches!(event, EventMsg::TaskComplete(_)), - Duration::from_secs(10), - ) - .await; + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 3db8050a5b..50918c1475 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -25,7 +25,6 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; -use core_test_support::wait_for_event_with_timeout; use regex_lite::Regex; use serde_json::Value; use serde_json::json; @@ -482,8 +481,6 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> { - use tokio::time::Duration; - skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -559,7 +556,7 @@ async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> { let mut begin_events = Vec::new(); loop { - let event_msg = wait_for_event_with_timeout(&codex, |_| true, Duration::from_secs(2)).await; + let event_msg = wait_for_event(&codex, |_| true).await; match event_msg { EventMsg::ExecCommandBegin(event) => begin_events.push(event), EventMsg::TaskComplete(_) => break, From e84e39940b31e414b03976fdf4c51a3565102e11 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Thu, 6 Nov 2025 19:43:13 -0800 Subject: [PATCH 066/122] [App-server] Implement `account/read` endpoint (#6336) This PR does two things: 1. add a new function in core that maps the core-internal plan type to the external plan type; 2. implement account/read that get account status (v2 of `getAuthStatus`). --- .../src/protocol/common.rs | 17 +- .../app-server-protocol/src/protocol/v2.rs | 18 +- .../app-server/src/codex_message_processor.rs | 71 +++++- .../app-server/tests/common/mcp_process.rs | 10 + codex-rs/app-server/tests/suite/v2/account.rs | 231 ++++++++++++++++-- codex-rs/core/src/auth.rs | 88 ++++++- 6 files changed, 384 insertions(+), 51 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a7ae035d48..2c52067bbd 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -191,7 +191,7 @@ client_request_definitions! { #[serde(rename = "account/read")] #[ts(rename = "account/read")] GetAccount { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + params: v2::GetAccountParams, response: v2::GetAccountResponse, }, @@ -263,6 +263,7 @@ client_request_definitions! { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v1::LogoutChatGptResponse, }, + /// DEPRECATED in favor of GetAccount GetAuthStatus { params: v1::GetAuthStatusParams, response: v1::GetAuthStatusResponse, @@ -758,12 +759,17 @@ mod tests { fn serialize_get_account() -> Result<()> { let request = ClientRequest::GetAccount { request_id: RequestId::Integer(5), - params: None, + params: v2::GetAccountParams { + refresh_token: false, + }, }; assert_eq!( json!({ "method": "account/read", "id": 5, + "params": { + "refreshToken": false + } }), serde_json::to_value(&request)?, ); @@ -772,19 +778,16 @@ mod tests { #[test] fn account_serializes_fields_in_camel_case() -> Result<()> { - let api_key = v2::Account::ApiKey { - api_key: "secret".to_string(), - }; + let api_key = v2::Account::ApiKey {}; assert_eq!( json!({ "type": "apiKey", - "apiKey": "secret", }), serde_json::to_value(&api_key)?, ); let chatgpt = v2::Account::Chatgpt { - email: Some("user@example.com".to_string()), + email: "user@example.com".to_string(), plan_type: PlanType::Plus, }; assert_eq!( diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 436585ba17..6d2a681fa2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -123,14 +123,11 @@ impl From for SandboxPolicy { pub enum Account { #[serde(rename = "apiKey", rename_all = "camelCase")] #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { api_key: String }, + ApiKey {}, #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - email: Option, - plan_type: PlanType, - }, + Chatgpt { email: String, plan_type: PlanType }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -193,11 +190,20 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits: RateLimitSnapshot, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + #[serde(default)] + pub refresh_token: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GetAccountResponse { - pub account: Account, + pub account: Option, + pub requires_openai_auth: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ebfaed58c1..b6549cc153 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,7 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -30,7 +31,9 @@ use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; @@ -273,12 +276,8 @@ impl CodexMessageProcessor { ClientRequest::CancelLoginAccount { request_id, params } => { self.cancel_login_v2(request_id, params).await; } - ClientRequest::GetAccount { - request_id, - params: _, - } => { - self.send_unimplemented_error(request_id, "account/read") - .await; + ClientRequest::GetAccount { request_id, params } => { + self.get_account(request_id, params).await; } ClientRequest::ResumeConversation { request_id, params } => { self.handle_resume_conversation(request_id, params).await; @@ -801,13 +800,17 @@ impl CodexMessageProcessor { } } + async fn refresh_token_if_requested(&self, do_refresh: bool) { + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + tracing::warn!("failed to refresh token whilte getting account: {err}"); + } + } + async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token while getting auth status: {err}"); - } + self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -852,6 +855,56 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn get_account(&self, request_id: RequestId, params: GetAccountParams) { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + // Whether auth is required for the active model provider. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + if !requires_openai_auth { + let response = GetAccountResponse { + account: None, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + return; + } + + let account = match self.auth_manager.auth() { + Some(auth) => Some(match auth.mode { + AuthMode::ApiKey => Account::ApiKey {}, + AuthMode::ChatGPT => { + let email = auth.get_account_email(); + let plan_type = auth.account_plan_type(); + + match (email, plan_type) { + (Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type }, + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: + "email and plan type are required for chatgpt authentication" + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + }), + None => None, + }; + + let response = GetAccountResponse { + account, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + } + async fn get_user_agent(&self, request_id: RequestId) { let user_agent = get_codex_user_agent(); let response = GetUserAgentResponse { user_agent }; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index b4404b47d8..75851eda2c 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; @@ -249,6 +250,15 @@ impl McpProcess { self.send_request("account/rateLimits/read", None).await } + /// Send an `account/read` JSON-RPC request. + pub async fn send_get_account_request( + &mut self, + params: GetAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/read", params).await + } + /// Send a `feedback/upload` JSON-RPC request. pub async fn send_feedback_upload_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index a00c97b11e..dd59270739 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -2,11 +2,15 @@ use anyhow::Result; use anyhow::bail; use app_test_support::McpProcess; use app_test_support::to_response; + +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; -use codex_app_server_protocol::GetAuthStatusParams; -use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; @@ -15,6 +19,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use serial_test::serial; use std::path::Path; @@ -25,22 +30,30 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // Helper to create a minimal config.toml for the app server -fn create_config_toml( - codex_home: &Path, - forced_method: Option<&str>, - forced_workspace_id: Option<&str>, -) -> std::io::Result<()> { +#[derive(Default)] +struct CreateConfigTomlParams { + forced_method: Option, + forced_workspace_id: Option, + requires_openai_auth: Option, +} + +fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); - let forced_line = if let Some(method) = forced_method { + let forced_line = if let Some(method) = params.forced_method { format!("forced_login_method = \"{method}\"\n") } else { String::new() }; - let forced_workspace_line = if let Some(ws) = forced_workspace_id { + let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { format!("forced_chatgpt_workspace_id = \"{ws}\"\n") } else { String::new() }; + let requires_line = match params.requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) => String::new(), + None => String::new(), + }; let contents = format!( r#" model = "mock-model" @@ -57,6 +70,7 @@ base_url = "http://127.0.0.1:0/v1" wire_api = "chat" request_max_retries = 0 stream_max_retries = 0 +{requires_line} "# ); std::fs::write(config_toml, contents) @@ -65,7 +79,7 @@ stream_max_retries = 0 #[tokio::test] async fn logout_account_removes_auth_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; login_with_api_key( codex_home.path(), @@ -104,27 +118,25 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { "auth.json should be deleted" ); - let status_id = mcp - .send_get_auth_status_request(GetAuthStatusParams { - include_token: Some(true), - refresh_token: Some(false), + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, }) .await?; - let status_resp: JSONRPCResponse = timeout( + let get_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(status_id)), + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), ) .await??; - let status: GetAuthStatusResponse = to_response(status_resp)?; - assert_eq!(status.auth_method, None); - assert_eq!(status.auth_token, None); + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!(account.account, None); Ok(()) } #[tokio::test] async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -171,7 +183,13 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { #[tokio::test] async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), Some("chatgpt"), None)?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("chatgpt".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -195,7 +213,13 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { #[tokio::test] async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), Some("api"), None)?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("api".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -219,7 +243,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { #[serial(login_port)] async fn login_account_chatgpt_start() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, None)?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -285,7 +309,13 @@ async fn login_account_chatgpt_start() -> Result<()> { #[serial(login_port)] async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), None, Some("ws-forced"))?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("ws-forced".to_string()), + ..Default::default() + }, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -307,3 +337,156 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result ); Ok(()) } + +#[tokio::test] +async fn get_account_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let account: GetAccountResponse = to_response(resp)?; + + assert_eq!(account.account, None, "expected no account"); + assert_eq!(account.requires_openai_auth, true); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let _login_ok = to_response::(resp)?; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::ApiKey {}), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(false), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: None, + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 655d8230e4..a9d6406001 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -26,10 +26,12 @@ use crate::config::Config; use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; -use crate::token_data::PlanType; +use crate::token_data::KnownPlan as InternalKnownPlan; +use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_protocol::account::PlanType as AccountPlanType; use serde_json::Value; use thiserror::Error; @@ -202,7 +204,34 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.id_token.email) } - pub(crate) fn get_plan_type(&self) -> Option { + /// Account-facing plan classification derived from the current token. + /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) + /// mapped from the ID token's internal plan value. Prefer this when you + /// need to make UI or product decisions based on the user's subscription. + pub fn account_plan_type(&self) -> Option { + let map_known = |kp: &InternalKnownPlan| match kp { + InternalKnownPlan::Free => AccountPlanType::Free, + InternalKnownPlan::Plus => AccountPlanType::Plus, + InternalKnownPlan::Pro => AccountPlanType::Pro, + InternalKnownPlan::Team => AccountPlanType::Team, + InternalKnownPlan::Business => AccountPlanType::Business, + InternalKnownPlan::Enterprise => AccountPlanType::Enterprise, + InternalKnownPlan::Edu => AccountPlanType::Edu, + }; + + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type) + .map(|pt| match pt { + InternalPlanType::Known(k) => map_known(&k), + InternalPlanType::Unknown(_) => AccountPlanType::Unknown, + }) + } + + /// Raw internal plan value from the ID token. + /// Exposes the underlying `token_data::PlanType` without mapping it to the + /// public `AccountPlanType`. Use this when downstream code needs to inspect + /// internal/unknown plan strings exactly as issued in the token. + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() .and_then(|t| t.id_token.chatgpt_plan_type) } @@ -609,8 +638,9 @@ mod tests { use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; + use crate::token_data::KnownPlan as InternalKnownPlan; + use crate::token_data::PlanType as InternalPlanType; + use codex_protocol::account::PlanType as AccountPlanType; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; @@ -727,7 +757,7 @@ mod tests { tokens: Some(TokenData { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), chatgpt_account_id: None, raw_jwt: fake_jwt, }, @@ -981,6 +1011,54 @@ mod tests { .contains("ChatGPT login is required, but an API key is currently being used.") ); } + + #[test] + fn plan_type_maps_known_plan() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Known(InternalKnownPlan::Pro)) + ); + } + + #[test] + fn plan_type_maps_unknown_to_unknown() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "mystery-tier".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Unknown("mystery-tier".to_string())) + ); + } } /// Central manager providing a single source of truth for auth.json derived From 2030b28083ef21e45f878f81eb934cdff89ad5f2 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Thu, 6 Nov 2025 20:08:45 -0800 Subject: [PATCH 067/122] [app-server] feat: expose additional fields on Thread (#6338) Add the following fields to Thread: ``` pub preview: String, pub model_provider: String, pub created_at: i64, ``` Will prob need another PR once this lands: https://github.com/openai/codex/pull/6337 --- .../app-server-protocol/src/protocol/v2.rs | 5 ++ .../app-server/src/codex_message_processor.rs | 75 +++++++++++++++---- .../app-server/tests/suite/v2/thread_list.rs | 15 ++++ .../tests/suite/v2/thread_resume.rs | 2 +- .../app-server/tests/suite/v2/thread_start.rs | 11 ++- 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6d2a681fa2..2afdb4575a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -354,6 +354,11 @@ pub struct ThreadCompactResponse {} #[ts(export_to = "v2/")] pub struct Thread { pub id: String, + /// Usually the first user message in the thread, if available. + pub preview: String, + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + pub created_at: i64, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b6549cc153..12bbdf71fb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4,6 +4,8 @@ use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use chrono::DateTime; +use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; @@ -1202,8 +1204,31 @@ impl CodexMessageProcessor { match self.conversation_manager.new_conversation(config).await { Ok(new_conv) => { - let thread = Thread { - id: new_conv.conversation_id.to_string(), + let conversation_id = new_conv.conversation_id; + let rollout_path = new_conv.session_configured.rollout_path.clone(); + let fallback_provider = self.config.model_provider_id.as_str(); + + // A bit hacky, but the summary contains a lot of useful information for the thread + // that unfortunately does not get returned from conversation_manager.new_conversation(). + let thread = match read_summary_from_rollout( + rollout_path.as_path(), + fallback_provider, + ) + .await + { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + warn!( + "failed to load summary for new thread {}: {}", + conversation_id, err + ); + Thread { + id: conversation_id.to_string(), + preview: String::new(), + model_provider: self.config.model_provider_id.clone(), + created_at: chrono::Utc::now().timestamp(), + } + } }; let response = ThreadStartResponse { @@ -1213,12 +1238,12 @@ impl CodexMessageProcessor { // Auto-attach a conversation listener when starting a thread. // Use the same behavior as the v1 API with experimental_raw_events=false. if let Err(err) = self - .attach_conversation_listener(new_conv.conversation_id, false) + .attach_conversation_listener(conversation_id, false) .await { tracing::warn!( "failed to attach listener for conversation {}: {}", - new_conv.conversation_id, + conversation_id, err.message ); } @@ -1316,12 +1341,7 @@ impl CodexMessageProcessor { } }; - let data = summaries - .into_iter() - .map(|s| Thread { - id: s.conversation_id.to_string(), - }) - .collect(); + let data = summaries.into_iter().map(summary_to_thread).collect(); let response = ThreadListResponse { data, next_cursor }; self.outgoing.send_response(request_id, response).await; @@ -1408,6 +1428,8 @@ impl CodexMessageProcessor { .await { Ok(_) => { + let thread = summary_to_thread(summary); + // Auto-attach a conversation listener when resuming a thread. if let Err(err) = self .attach_conversation_listener(conversation_id, false) @@ -1420,11 +1442,7 @@ impl CodexMessageProcessor { ); } - let response = ThreadResumeResponse { - thread: Thread { - id: conversation_id.to_string(), - }, - }; + let response = ThreadResumeResponse { thread }; self.outgoing.send_response(request_id, response).await; } Err(err) => { @@ -2837,6 +2855,33 @@ fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo { } } +fn parse_datetime(timestamp: Option<&str>) -> Option> { + timestamp.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + }) +} + +fn summary_to_thread(summary: ConversationSummary) -> Thread { + let ConversationSummary { + conversation_id, + preview, + timestamp, + model_provider, + .. + } = summary; + + let created_at = parse_datetime(timestamp.as_deref()); + + Thread { + id: conversation_id.to_string(), + preview, + model_provider, + created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + } +} + #[cfg(test)] mod tests { use super::*; 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 43e243b0ff..09ef0ebfd2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -102,6 +102,11 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { next_cursor: cursor1, } = to_response::(page1_resp)?; assert_eq!(data1.len(), 2); + for thread in &data1 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + } let cursor1 = cursor1.expect("expected nextCursor on first page"); // Page 2: with cursor → expect next_cursor None when no more results. @@ -122,6 +127,11 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { next_cursor: cursor2, } = to_response::(page2_resp)?; assert!(data2.len() <= 2); + for thread in &data2 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + } assert_eq!(cursor2, None, "expected nextCursor to be null on last page"); Ok(()) @@ -200,6 +210,11 @@ async fn thread_list_respects_provider_filter() -> Result<()> { let ThreadListResponse { data, next_cursor } = to_response::(resp)?; assert_eq!(data.len(), 1); assert_eq!(next_cursor, None); + let thread = &data[0]; + assert_eq!(thread.preview, "X"); + assert_eq!(thread.model_provider, "other_provider"); + let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_ts); Ok(()) } 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 039f542fd7..45fd974a97 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -49,7 +49,7 @@ async fn thread_resume_returns_existing_thread() -> Result<()> { .await??; let ThreadResumeResponse { thread: resumed } = to_response::(resume_resp)?; - assert_eq!(resumed.id, thread.id); + assert_eq!(resumed, thread); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 8b4cd5571d..ac2420916b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -42,6 +42,15 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .await??; let ThreadStartResponse { thread } = to_response::(resp)?; assert!(!thread.id.is_empty(), "thread id should not be empty"); + assert!( + thread.preview.is_empty(), + "new threads should start with an empty preview" + ); + assert_eq!(thread.model_provider, "mock_provider"); + assert!( + thread.created_at > 0, + "created_at should be a positive UNIX timestamp" + ); // A corresponding thread/started notification should arrive. let notif: JSONRPCNotification = timeout( @@ -51,7 +60,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .await??; let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; - assert_eq!(started.thread.id, thread.id); + assert_eq!(started.thread, thread); Ok(()) } From 2e81f1900da465f1e65c3edfe212be715d707448 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 7 Nov 2025 08:17:19 -0800 Subject: [PATCH 068/122] [App-server] Add auth v2 doc & update codex mcp interface auth section (#6353) Added doc for auth v2 endpoints. Updated the auth section in Codex MCP interface doc too. --- codex-rs/app-server/README.md | 95 ++++++++++++++++++++++++++++ codex-rs/docs/codex_mcp_interface.md | 9 +-- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2eb8a1ad72..ee94bd3479 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -13,3 +13,98 @@ Currently, you can dump a TypeScript version of the schema using `codex generate ``` codex generate-ts --out DIR ``` + +## Auth endpoints (v2) + +The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. + +### Quick reference +- `account/read` — fetch current account info; optionally refresh tokens. +- `account/login/start` — begin login (`apiKey` or `chatgpt`). +- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). +- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. +- `account/logout` — sign out; triggers `account/updated`. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`). +- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). + +### 1) Check auth state + +Request: +```json +{ "method": "account/read", "id": 1, "params": { "refreshToken": false } } +``` + +Response examples: +```json +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // no auth needed +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // auth needed +{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +``` + +Field notes: +- `refreshToken` (bool): set `true` to force a token refresh. +- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. + +### 2) Log in with an API key + +1. Send: + ```json + { "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } } + ``` +2. Expect: + ```json + { "id": 2, "result": { "type": "apiKey" } } + ``` +3. Notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "apikey" } } + ``` + +### 3) Log in with ChatGPT (browser flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 3, "params": { "type": "chatgpt" } } + { "id": 3, "result": { "type": "chatgpt", "loginId": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%2Fauth%2Fcallback" } } + ``` +2. Open `authUrl` in a browser; the app-server hosts the local callback. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt" } } + ``` + +### 4) Cancel a ChatGPT login + +```json +{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "" } } +{ "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } +``` + +### 5) Logout + +```json +{ "method": "account/logout", "id": 5 } +{ "id": 5, "result": {} } +{ "method": "account/updated", "params": { "authMode": null } } +``` + +### 6) Rate limits (ChatGPT) + +```json +{ "method": "account/rateLimits/read", "id": 6 } +{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } +``` + +Field notes: +- `usedPercent` is current usage within the OpenAI quota window. +- `windowDurationMins` is the quota window length. +- `resetsAt` is a Unix timestamp (seconds) for the next reset. + +### Dev notes + +- `codex generate-ts --out ` emits v2 typings under `v2/`. +- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 5945db8505..9b3a959438 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -21,7 +21,8 @@ At a glance: - `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo` - `model/list` → enumerate available models and reasoning options - Auth - - `loginApiKey`, `loginChatGpt`, `cancelLoginChatGpt`, `logoutChatGpt`, `getAuthStatus` + - `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read` + - notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated` - Utilities - `gitDiffToRemote`, `execOneOffCommand` - Approvals (server → client requests) @@ -113,11 +114,7 @@ The client must reply with `{ decision: "allow" | "deny" }` for each request. ## Auth helpers -For ChatGPT or API‑key based auth flows, the server exposes helpers: - -- `loginApiKey { apiKey }` -- `loginChatGpt` → returns `{ loginId, authUrl }`; browser completes flow; then `loginChatGptComplete` notification follows -- `cancelLoginChatGpt { loginId }`, `logoutChatGpt`, `getAuthStatus { includeToken?, refreshToken? }` +For the complete request/response shapes and flow examples, see the [“Auth endpoints (v2)” section in the app‑server README](../app-server/README.md#auth-endpoints-v2). ## Example: start and send a message From 361d43b969f0b0c4e08f3ab53ede22bbdee06c71 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 7 Nov 2025 09:02:49 -0800 Subject: [PATCH 069/122] [app-server] doc: update README for threads and turns (#6368) Self explanatory! --- codex-rs/app-server/README.md | 135 +++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ee94bd3479..8f88d79e72 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,6 +1,6 @@ # codex-app-server -`codex app-server` is the harness Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable. +`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable. ## Protocol @@ -14,7 +14,136 @@ Currently, you can dump a TypeScript version of the schema using `codex generate codex generate-ts --out DIR ``` -## Auth endpoints (v2) +## Core primitives + +We have 3 top level primitives: +- Thread - a conversation between the Codex agent and a user. Each thread contains multiple turns. +- Turn - one turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. +- Item - represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. + +## Thread & turn endpoints + +The JSON-RPC API exposes dedicated methods for managing Codex conversations. Threads store long-lived conversation metadata, and turns store the per-message exchange (input → Codex output, including streamed items). Use the thread APIs to create, list, or archive sessions, then drive the conversation with turn APIs and notifications. + +### Quick reference +- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. +- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. +- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. +- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. + +### 1) Start or resume a thread + +Start a fresh thread when you need a new Codex conversation. Optional fields mirror CLI defaults: set `model`, `modelProvider`, `cwd`, `approvalPolicy`, `sandbox`, or custom `config` values. Instructions can be set via `baseInstructions` and `developerInstructions`: + +```json +{ "method": "thread/start", "id": 10, "params": { + "model": "gpt-5-codex", + "cwd": "/Users/me/project", + "approvalPolicy": "never", + "sandbox": "workspace-write", + "baseInstructions": "You're helping with refactors." +} } +{ "id": 10, "result": { + "thread": { + "id": "thr_123", + "preview": "", + "modelProvider": "openai", + "createdAt": 1730910000 + } +} } +{ "method": "thread/started", "params": { "thread": { … } } } +``` + +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted: + +```json +{ "method": "thread/resume", "id": 11, "params": { "threadId": "thr_123" } } +{ "id": 11, "result": { "thread": { "id": "thr_123", … } } } +``` + +### 2) List threads (pagination & filters) + +`thread/list` lets you render a history UI. Pass any combination of: +- `cursor` — opaque string from a prior response; omit for the first page. +- `limit` — server defaults to a reasonable page size if unset. +- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. + +Example: + +```json +{ "method": "thread/list", "id": 20, "params": { + "cursor": null, + "limit": 25, + "modelProviders": ["openai"] +} } +{ "id": 20, "result": { + "data": [ + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 } + ], + "nextCursor": "opaque-token-or-null" +} } +``` + +When `nextCursor` is `null`, you’ve reached the final page. + +### 3) Archive a thread + +Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory. + +```json +{ "method": "thread/archive", "id": 21, "params": { "threadId": "thr_b" } } +{ "id": 21, "result": {} } +``` + +An archived thread will not appear in future calls to `thread/list`. + +### 4) Start a turn (send user input) + +Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: + +- `{"type":"text","text":"Explain this diff"}` +- `{"type":"image","url":"https://…png"}` +- `{"type":"localImage","path":"/tmp/screenshot.png"}` + +Override knobs apply to the new turn and become the defaults for subsequent turns on the same thread: + +```json +{ "method": "turn/start", "id": 30, "params": { + "threadId": "thr_123", + "input": [ { "type": "text", "text": "Run tests" } ], + "cwd": "/Users/me/project", + "approvalPolicy": "untrusted", + "sandboxPolicy": "workspace-write", + "model": "gpt-5-codex", + "effort": "medium", + "summary": "focus-on-test-failures" +} } +{ "id": 30, "result": { "turn": { + "id": "turn_456", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### 5) Interrupt an active turn + +You can cancel a running Turn with `turn/interrupt`. + +```json +{ "method": "turn/interrupt", "id": 31, "params": { + "threadId": "thr_123", + "turnId": "turn_456" +} } +{ "id": 31, "result": {} } +``` + +The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done. + +## Auth endpoints The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. @@ -106,5 +235,5 @@ Field notes: ### Dev notes -- `codex generate-ts --out ` emits v2 typings under `v2/`. +- `codex generate-ts --out ` emits v2 types under `v2/`. - See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. From 4c1a6f0ee0e79b3ddaa4e20155af817a5fbb25e2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 7 Nov 2025 10:11:11 -0800 Subject: [PATCH 070/122] Promote shell config tool to model family config (#6351) --- codex-rs/core/src/model_family.rs | 19 ++++++++++--------- codex-rs/core/src/tools/spec.rs | 26 ++++++++++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index a04443611b..136af16b54 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -1,5 +1,6 @@ use crate::config::types::ReasoningSummaryFormat; use crate::tools::handlers::apply_patch::ApplyPatchToolType; +use crate::tools::spec::ConfigShellToolType; /// The `instructions` field in the payload sent to a model should always start /// with this content. @@ -29,12 +30,6 @@ pub struct ModelFamily { // Define if we need a special handling of reasoning summary pub reasoning_summary_format: ReasoningSummaryFormat, - // This should be set to true when the model expects a tool named - // "local_shell" to be provided. Its contract must be understood natively by - // the model such that its description can be omitted. - // See https://platform.openai.com/docs/guides/tools-local-shell - pub uses_local_shell_tool: bool, - /// Whether this model supports parallel tool calls when using the /// Responses API. pub supports_parallel_tool_calls: bool, @@ -57,6 +52,9 @@ pub struct ModelFamily { /// If the model family supports setting the verbosity level when using Responses API. pub support_verbosity: bool, + + /// Preferred shell tool type for this model family when features do not override it. + pub shell_type: ConfigShellToolType, } macro_rules! model_family { @@ -64,19 +62,20 @@ macro_rules! model_family { $slug:expr, $family:expr $(, $key:ident : $value:expr )* $(,)? ) => {{ // defaults + #[allow(unused_mut)] let mut mf = ModelFamily { slug: $slug.to_string(), family: $family.to_string(), needs_special_apply_patch_instructions: false, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, - uses_local_shell_tool: false, supports_parallel_tool_calls: false, apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), effective_context_window_percent: 95, support_verbosity: false, + shell_type: ConfigShellToolType::Default, }; // apply overrides $( @@ -105,8 +104,8 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!( slug, "codex-mini-latest", supports_reasoning_summaries: true, - uses_local_shell_tool: true, needs_special_apply_patch_instructions: true, + shell_type: ConfigShellToolType::Local, ) } else if slug.starts_with("gpt-4.1") { model_family!( @@ -119,6 +118,8 @@ pub fn find_family_for_model(slug: &str) -> Option { model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true) } else if slug.starts_with("gpt-3.5") { model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true) + } else if slug.starts_with("porcupine") { + model_family!(slug, "porcupine", shell_type: ConfigShellToolType::UnifiedExec) } else if slug.starts_with("test-gpt-5-codex") { model_family!( slug, slug, @@ -181,12 +182,12 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily { needs_special_apply_patch_instructions: false, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, - uses_local_shell_tool: false, supports_parallel_tool_calls: false, apply_patch_tool_type: None, base_instructions: BASE_INSTRUCTIONS.to_string(), experimental_supported_tools: Vec::new(), effective_context_window_percent: 95, support_verbosity: false, + shell_type: ConfigShellToolType::Default, } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 8f4e6f35e7..1e4a0d9e68 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -15,7 +15,7 @@ use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ConfigShellToolType { Default, Local, @@ -42,17 +42,14 @@ impl ToolsConfig { model_family, features, } = params; - let use_unified_exec_tool = features.enabled(Feature::UnifiedExec); let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_web_search_request = features.enabled(Feature::WebSearchRequest); let include_view_image_tool = features.enabled(Feature::ViewImageTool); - let shell_type = if use_unified_exec_tool { + let shell_type = if features.enabled(Feature::UnifiedExec) { ConfigShellToolType::UnifiedExec - } else if model_family.uses_local_shell_tool { - ConfigShellToolType::Local } else { - ConfigShellToolType::Default + model_family.shell_type.clone() }; let apply_patch_tool_type = match model_family.apply_patch_tool_type { @@ -1212,6 +1209,23 @@ mod tests { ); } + #[test] + fn test_porcupine_defaults() { + assert_model_tools( + "porcupine", + &Features::with_defaults(), + &[ + "exec_command", + "write_stdin", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ); + } + #[test] fn test_codex_mini_unified_exec_web_search() { assert_model_tools( From c6ab92bc50d673f86de578d594d7305f2883665b Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:17:52 -0800 Subject: [PATCH 071/122] tui: add comments to tui.rs (#6369) --- codex-rs/tui/src/tui.rs | 182 +++++++++++++++++++++++----------------- 1 file changed, 103 insertions(+), 79 deletions(-) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index ca672355dc..39849471b5 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -22,6 +22,8 @@ use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; use crossterm::event::Event; +#[cfg(unix)] +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; @@ -39,12 +41,17 @@ use ratatui::text::Line; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; +#[cfg(unix)] +use crate::key_hint; use tokio::select; use tokio_stream::Stream; /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +#[cfg(unix)] +const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); + pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -217,60 +224,11 @@ impl FrameRequester { } impl Tui { - /// Emit a desktop notification now if the terminal is unfocused. - /// Returns true if a notification was posted. - pub fn notify(&mut self, message: impl AsRef) -> bool { - if !self.terminal_focused.load(Ordering::Relaxed) { - let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); - true - } else { - false - } - } pub fn new(terminal: Terminal) -> Self { let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); let (draw_tx, _) = tokio::sync::broadcast::channel(1); - // Spawn background scheduler to coalesce frame requests and emit draws at deadlines. - let draw_tx_clone = draw_tx.clone(); - tokio::spawn(async move { - use tokio::select; - use tokio::time::Instant as TokioInstant; - use tokio::time::sleep_until; - - let mut rx = frame_schedule_rx; - let mut next_deadline: Option = None; - - loop { - let target = next_deadline - .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); - let sleep_fut = sleep_until(TokioInstant::from_std(target)); - tokio::pin!(sleep_fut); - - select! { - recv = rx.recv() => { - match recv { - Some(at) => { - if next_deadline.is_none_or(|cur| at < cur) { - next_deadline = Some(at); - } - // Do not send a draw immediately here. By continuing the loop, - // we recompute the sleep target so the draw fires once via the - // sleep branch, coalescing multiple requests into a single draw. - continue; - } - None => break, - } - } - _ = &mut sleep_fut => { - if next_deadline.is_some() { - next_deadline = None; - let _ = draw_tx_clone.send(()); - } - } - } - } - }); + spawn_frame_scheduler(frame_schedule_rx, draw_tx.clone()); // Detect keyboard enhancement support before any EventStream is created so the // crossterm poller can acquire its lock without contention. @@ -305,16 +263,46 @@ impl Tui { self.enhanced_keys_supported } + /// Emit a desktop notification now if the terminal is unfocused. + /// Returns true if a notification was posted. + pub fn notify(&mut self, message: impl AsRef) -> bool { + if !self.terminal_focused.load(Ordering::Relaxed) { + let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); + true + } else { + false + } + } + pub fn event_stream(&self) -> Pin + Send + 'static>> { use tokio_stream::StreamExt; + let mut crossterm_events = crossterm::event::EventStream::new(); let mut draw_rx = self.draw_tx.subscribe(); + + // State for tracking how we should resume from ^Z suspend. #[cfg(unix)] let resume_pending = self.resume_pending.clone(); #[cfg(unix)] let alt_screen_active = self.alt_screen_active.clone(); #[cfg(unix)] let suspend_cursor_y = self.suspend_cursor_y.clone(); + + #[cfg(unix)] + let suspend = move || { + if alt_screen_active.load(Ordering::Relaxed) { + // Disable alternate scroll when suspending from alt-screen + let _ = execute!(stdout(), DisableAlternateScroll); + let _ = execute!(stdout(), LeaveAlternateScreen); + resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed); + } else { + resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed); + } + let y = suspend_cursor_y.load(Ordering::Relaxed); + let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show); + let _ = Tui::suspend(); + }; + let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { loop { @@ -323,31 +311,9 @@ impl Tui { match event { crossterm::event::Event::Key(key_event) => { #[cfg(unix)] - if matches!( - key_event, - crossterm::event::KeyEvent { - code: crossterm::event::KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: crossterm::event::KeyEventKind::Press, - .. - } - ) - { - if alt_screen_active.load(Ordering::Relaxed) { - // Disable alternate scroll when suspending from alt-screen - let _ = execute!(stdout(), DisableAlternateScroll); - let _ = execute!(stdout(), LeaveAlternateScreen); - resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed); - } else { - resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed); - } - #[cfg(unix)] - { - let y = suspend_cursor_y.load(Ordering::Relaxed); - let _ = execute!(stdout(), MoveTo(0, y)); - } - let _ = execute!(stdout(), crossterm::cursor::Show); - let _ = Tui::suspend(); + if SUSPEND_KEY.is_press(key_event) { + suspend(); + // We continue here after resume. yield TuiEvent::Draw; continue; } @@ -389,6 +355,7 @@ impl Tui { }; Box::pin(event_stream) } + #[cfg(unix)] fn suspend() -> Result<()> { restore()?; @@ -397,6 +364,8 @@ impl Tui { Ok(()) } + /// When resuming from ^Z suspend, we want to put things back the way they were before suspend. + /// We capture the action in an object so we can pass it into the event stream, since the relevant #[cfg(unix)] fn prepare_resume_action( &mut self, @@ -490,12 +459,15 @@ impl Tui { height: u16, draw_fn: impl FnOnce(&mut custom_terminal::Frame), ) -> Result<()> { - // Precompute any viewport updates that need a cursor-position query before entering - // the synchronized update, to avoid racing with the event reader. - let mut pending_viewport_area: Option = None; + // If we are resuming from ^Z, we need to prepare the resume action now so we can apply it + // in the synchronized update. #[cfg(unix)] let mut prepared_resume = self.prepare_resume_action(take_resume_action(&self.resume_pending))?; + + // Precompute any viewport updates that need a cursor-position query before entering + // the synchronized update, to avoid racing with the event reader. + let mut pending_viewport_area: Option = None; { let terminal = &mut self.terminal; let screen_size = terminal.size()?; @@ -504,6 +476,9 @@ impl Tui { && let Ok(cursor_pos) = terminal.get_cursor_position() { let last_known_cursor_pos = terminal.last_known_cursor_pos; + // If we resized AND the cursor moved, we adjust the viewport area to keep the + // cursor in the same position. This is a heuristic that seems to work well + // at least in iTerm2. if cursor_pos.y != last_known_cursor_pos.y { let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; let new_viewport_area = terminal.viewport_area.offset(Offset { @@ -515,7 +490,6 @@ impl Tui { } } - // Use synchronized update via backend instead of stdout() std::io::stdout().sync_update(|_| { #[cfg(unix)] { @@ -534,6 +508,7 @@ impl Tui { let mut area = terminal.viewport_area; area.height = height.min(size.height); area.width = size.width; + // If the viewport has expanded, scroll everything else up to make room. if area.bottom() > size.height { terminal .backend_mut() @@ -541,9 +516,11 @@ impl Tui { area.y = size.height - area.height; } if area != terminal.viewport_area { + // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. terminal.clear()?; terminal.set_viewport_area(area); } + if !self.pending_history_lines.is_empty() { crate::insert_history::insert_history_lines( terminal, @@ -551,6 +528,7 @@ impl Tui { )?; self.pending_history_lines.clear(); } + // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] { @@ -564,6 +542,7 @@ impl Tui { self.suspend_cursor_y .store(inline_area_bottom, Ordering::Relaxed); } + terminal.draw(|frame| { draw_fn(frame); }) @@ -571,6 +550,51 @@ impl Tui { } } +/// Spawn background scheduler to coalesce frame requests and emit draws at deadlines. +fn spawn_frame_scheduler( + frame_schedule_rx: tokio::sync::mpsc::UnboundedReceiver, + draw_tx: tokio::sync::broadcast::Sender<()>, +) { + tokio::spawn(async move { + use tokio::select; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + + let mut rx = frame_schedule_rx; + let mut next_deadline: Option = None; + + loop { + let target = next_deadline + .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); + let sleep_fut = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleep_fut); + + select! { + recv = rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + // Do not send a draw immediately here. By continuing the loop, + // we recompute the sleep target so the draw fires once via the + // sleep branch, coalescing multiple requests into a single draw. + continue; + } + None => break, + } + } + _ = &mut sleep_fut => { + if next_deadline.is_some() { + next_deadline = None; + let _ = draw_tx.send(()); + } + } + } + } + }); +} + /// Command that emits an OSC 9 desktop notification with a message. #[derive(Debug, Clone)] pub struct PostNotification(pub String); From bb47f2226f0a718cd1d5174984be0b7ef876648b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 7 Nov 2025 12:05:22 -0800 Subject: [PATCH 072/122] feat: add --promote-alpha option to create_github_release script (#6370) Historically, running `create_github_release --publish-release` would always publish a new release from latest `main`, which isn't always the best idea. We should really publish an alpha, let it bake, and then promote it. This PR introduces a new flag, `--promote-alpha`, which does exactly that. It also works with `--dry-run`, so you can sanity check the commit it will use as the base commit for the new release before running it for real. ```shell $ ./codex-rs/scripts/create_github_release --dry-run --promote-alpha 0.56.0-alpha.2 Publishing version 0.56.0 Running gh api GET /repos/openai/codex/git/refs/tags/rust-v0.56.0-alpha.2 Running gh api GET /repos/openai/codex/git/tags/7d4ef77bc35b011aa0c76c5cbe6cd7d3e53f1dfe Running gh api GET /repos/openai/codex/compare/main...8b49211e67d3c863df5ecc13fc5f88516a20fa69 Would publish version 0.56.0 using base commit 62474a30e8b4a69a8639ae0889bcf7f576dc82f7 derived from rust-v0.56.0-alpha.2. ``` --- codex-rs/scripts/create_github_release | 72 ++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index e121fc5c5e..47d6d3ce8c 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -21,6 +21,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: action="store_true", help="Print the version that would be used and exit before making changes.", ) + parser.add_argument( + "--promote-alpha", + metavar="VERSION", + help="Promote an existing alpha tag (e.g., 0.56.0-alpha.5) by using its merge-base with main as the base commit.", + ) group = parser.add_mutually_exclusive_group() group.add_argument( @@ -43,26 +48,43 @@ def parse_args(argv: list[str]) -> argparse.Namespace: args.publish_alpha or args.publish_release or args.emergency_version_override + or args.promote_alpha ): parser.error( - "Must specify --publish-alpha, --publish-release, or --emergency-version-override." + "Must specify --publish-alpha, --publish-release, --promote-alpha, or --emergency-version-override." ) return args def main(argv: list[str]) -> int: args = parse_args(argv) + + # Strip the leading "v" if present. + promote_alpha = args.promote_alpha + if promote_alpha and promote_alpha.startswith("v"): + promote_alpha = promote_alpha[1:] + try: - if args.emergency_version_override: + if promote_alpha: + version = derive_release_version_from_alpha(promote_alpha) + elif args.emergency_version_override: version = args.emergency_version_override else: version = determine_version(args) print(f"Publishing version {version}") - if args.dry_run: + if promote_alpha: + base_commit = get_promote_alpha_base_commit(promote_alpha) + if args.dry_run: + print( + f"Would publish version {version} using base commit {base_commit} derived from rust-v{promote_alpha}." + ) + return 0 + elif args.dry_run: return 0 - print("Fetching branch head...") - base_commit = get_branch_head() + if not promote_alpha: + print("Fetching branch head...") + base_commit = get_branch_head() print(f"Base commit: {base_commit}") print("Fetching commit tree...") base_tree = get_commit_tree(base_commit) @@ -130,6 +152,39 @@ def get_branch_head() -> str: raise ReleaseError("Unable to determine branch head.") from error +def get_promote_alpha_base_commit(alpha_version: str) -> str: + tag_name = f"rust-v{alpha_version}" + tag_commit_sha = get_tag_commit_sha(tag_name) + return get_merge_base_with_main(tag_commit_sha) + + +def get_tag_commit_sha(tag_name: str) -> str: + response = run_gh_api(f"/repos/{REPO}/git/refs/tags/{tag_name}") + try: + sha = response["object"]["sha"] + obj_type = response["object"]["type"] + except KeyError as error: + raise ReleaseError(f"Unable to resolve tag {tag_name}.") from error + while obj_type == "tag": + tag_response = run_gh_api(f"/repos/{REPO}/git/tags/{sha}") + try: + sha = tag_response["object"]["sha"] + obj_type = tag_response["object"]["type"] + except KeyError as error: + raise ReleaseError(f"Unable to resolve annotated tag {tag_name}.") from error + if obj_type != "commit": + raise ReleaseError(f"Tag {tag_name} does not reference a commit.") + return sha + + +def get_merge_base_with_main(commit_sha: str) -> str: + response = run_gh_api(f"/repos/{REPO}/compare/main...{commit_sha}") + try: + return response["merge_base_commit"]["sha"] + except KeyError as error: + raise ReleaseError("Unable to determine merge base with main.") from error + + def get_commit_tree(commit_sha: str) -> str: response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}") try: @@ -309,5 +364,12 @@ def format_version(major: int, minor: int, patch: int) -> str: return f"{major}.{minor}.{patch}" +def derive_release_version_from_alpha(alpha_version: str) -> str: + match = re.match(r"^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$", alpha_version) + if match is None: + raise ReleaseError(f"Unexpected alpha version format: {alpha_version}") + return f"{match.group(1)}.{match.group(2)}.{match.group(3)}" + + if __name__ == "__main__": sys.exit(main(sys.argv)) From c76528ca1fcf5ed96cf8821a1236c84d7f791c3f Mon Sep 17 00:00:00 2001 From: Dan Hernandez <33551757+danfhernandez@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:19:34 -0600 Subject: [PATCH 073/122] [SDK] Add network_access and web_search options to TypeScript SDK (#6367) ## Summary This PR adds two new optional boolean fields to `ThreadOptions` in the TypeScript SDK: - **`networkAccess`**: Enables network access in the sandbox by setting `sandbox_workspace_write.network_access` config - **`webSearch`**: Enables the web search tool by setting `tools.web_search` config These options map to existing Codex configuration options and are properly threaded through the SDK layers: 1. `ThreadOptions` (threadOptions.ts) - User-facing API 2. `CodexExecArgs` (exec.ts) - Internal execution args 3. CLI flags via `--config` in the `codex exec` command ## Changes - `sdk/typescript/src/threadOptions.ts`: Added `networkAccess` and `webSearch` fields to `ThreadOptions` type - `sdk/typescript/src/exec.ts`: Added fields to `CodexExecArgs` and CLI flag generation - `sdk/typescript/src/thread.ts`: Pass options through to exec layer ## Test Plan - [x] Build succeeds (`pnpm build`) - [x] Linter passes (`pnpm lint`) - [x] Type definitions are properly exported - [ ] Manual testing with sample code (to be done by reviewer) --------- Co-authored-by: Claude --- sdk/typescript/src/exec.ts | 20 ++++++- sdk/typescript/src/thread.ts | 3 + sdk/typescript/src/threadOptions.ts | 3 + sdk/typescript/tests/run.test.ts | 93 +++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 5c7b1846e3..8086c92a44 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -3,7 +3,7 @@ import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { SandboxMode, ModelReasoningEffort } from "./threadOptions"; +import { SandboxMode, ModelReasoningEffort, ApprovalMode } from "./threadOptions"; export type CodexExecArgs = { input: string; @@ -24,6 +24,12 @@ export type CodexExecArgs = { outputSchemaFile?: string; // --config model_reasoning_effort modelReasoningEffort?: ModelReasoningEffort; + // --config sandbox_workspace_write.network_access + networkAccessEnabled?: boolean; + // --config features.web_search_request + webSearchEnabled?: boolean; + // --config approval_policy + approvalPolicy?: ApprovalMode; }; const INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; @@ -62,6 +68,18 @@ export class CodexExec { commandArgs.push("--config", `model_reasoning_effort="${args.modelReasoningEffort}"`); } + if (args.networkAccessEnabled !== undefined) { + commandArgs.push("--config", `sandbox_workspace_write.network_access=${args.networkAccessEnabled}`); + } + + if (args.webSearchEnabled !== undefined) { + commandArgs.push("--config", `features.web_search_request=${args.webSearchEnabled}`); + } + + if (args.approvalPolicy) { + commandArgs.push("--config", `approval_policy="${args.approvalPolicy}"`); + } + if (args.images?.length) { for (const image of args.images) { commandArgs.push("--image", image); diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index 0848852167..fec63cf4d4 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -86,6 +86,9 @@ export class Thread { skipGitRepoCheck: options?.skipGitRepoCheck, outputSchemaFile: schemaPath, modelReasoningEffort: options?.modelReasoningEffort, + networkAccessEnabled: options?.networkAccessEnabled, + webSearchEnabled: options?.webSearchEnabled, + approvalPolicy: options?.approvalPolicy, }); try { for await (const item of generator) { diff --git a/sdk/typescript/src/threadOptions.ts b/sdk/typescript/src/threadOptions.ts index 53895c9ab3..77d91b89e5 100644 --- a/sdk/typescript/src/threadOptions.ts +++ b/sdk/typescript/src/threadOptions.ts @@ -10,4 +10,7 @@ export type ThreadOptions = { workingDirectory?: string; skipGitRepoCheck?: boolean; modelReasoningEffort?: ModelReasoningEffort; + networkAccessEnabled?: boolean; + webSearchEnabled?: boolean; + approvalPolicy?: ApprovalMode; }; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index f6eefa0c5d..f461e16667 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -254,6 +254,99 @@ describe("Codex", () => { } }); + it("passes networkAccessEnabled to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Network access enabled", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + networkAccessEnabled: true, + }); + await thread.run("test network access"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", "sandbox_workspace_write.network_access=true"]); + } finally { + restore(); + await close(); + } + }); + + it("passes webSearchEnabled to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Web search enabled", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + webSearchEnabled: true, + }); + await thread.run("test web search"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", "features.web_search_request=true"]); + } finally { + restore(); + await close(); + } + }); + + it("passes approvalPolicy to exec", async () => { + const { url, close } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Approval policy set", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread({ + approvalPolicy: "on-request", + }); + await thread.run("test approval policy"); + + const commandArgs = spawnArgs[0]; + expect(commandArgs).toBeDefined(); + expectPair(commandArgs, ["--config", 'approval_policy="on-request"']); + } finally { + restore(); + await close(); + } + }); + it("writes output schema to a temporary file and forwards it", async () => { const { url, close, requests } = await startResponsesTestProxy({ statusCode: 200, From 2eecc1a2e4eb380ca9373c186c2b35945b3ecb17 Mon Sep 17 00:00:00 2001 From: Jakob Malmo Date: Fri, 7 Nov 2025 23:49:17 +0100 Subject: [PATCH 074/122] fix(wsl): normalize Windows paths during update (#6086) (#6097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running under WSL, the update command could receive Windows-style absolute paths (e.g., `C:\...`) and pass them to Linux processes unchanged, which fails because WSL expects those paths in `/mnt//...` form. This patch adds a tiny helper in the CLI (`cli/src/wsl_paths.rs`) that: - Detects WSL (`WSL_DISTRO_NAME` or `"microsoft"` in `/proc/version`) - Converts `X:\...` → `/mnt/x/...` `run_update_action` now normalizes the package-manager command and arguments under WSL before spawning. Non-WSL platforms are unaffected. Includes small unit tests for the converter. **Fixes:** #6086, #6084 Co-authored-by: Eric Traut --- codex-rs/cli/src/main.rs | 8 +++- codex-rs/cli/src/wsl_paths.rs | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 codex-rs/cli/src/wsl_paths.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 929fd9e785..d69eb5a10c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,8 +26,10 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; +mod wsl_paths; use crate::mcp_cmd::McpCli; +use crate::wsl_paths::normalize_for_wsl; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::features::is_known_feature_key; @@ -270,7 +272,11 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { let (cmd, args) = action.command_args(); let cmd_str = action.command_str(); println!("Updating Codex via `{cmd_str}`..."); - let status = std::process::Command::new(cmd).args(args).status()?; + let command_path = normalize_for_wsl(cmd); + let normalized_args: Vec = args.iter().map(normalize_for_wsl).collect(); + let status = std::process::Command::new(&command_path) + .args(&normalized_args) + .status()?; if !status.success() { anyhow::bail!("`{cmd_str}` failed with status {status}"); } diff --git a/codex-rs/cli/src/wsl_paths.rs b/codex-rs/cli/src/wsl_paths.rs new file mode 100644 index 0000000000..56ce8668cf --- /dev/null +++ b/codex-rs/cli/src/wsl_paths.rs @@ -0,0 +1,76 @@ +use std::ffi::OsStr; + +/// WSL-specific path helpers used by the updater logic. +/// +/// See https://github.com/openai/codex/issues/6086. +pub fn is_wsl() -> bool { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WSL_DISTRO_NAME").is_some() { + return true; + } + match std::fs::read_to_string("/proc/version") { + Ok(version) => version.to_lowercase().contains("microsoft"), + Err(_) => false, + } + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +/// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`). +/// Returns `None` if the input does not look like a Windows drive path. +pub fn win_path_to_wsl(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() < 3 + || bytes[1] != b':' + || !(bytes[2] == b'\\' || bytes[2] == b'/') + || !bytes[0].is_ascii_alphabetic() + { + return None; + } + let drive = (bytes[0] as char).to_ascii_lowercase(); + let tail = path[3..].replace('\\', "/"); + if tail.is_empty() { + return Some(format!("/mnt/{drive}")); + } + Some(format!("/mnt/{drive}/{tail}")) +} + +/// If under WSL and given a Windows-style path, return the equivalent `/mnt//…` path. +/// Otherwise returns the input unchanged. +pub fn normalize_for_wsl>(path: P) -> String { + let value = path.as_ref().to_string_lossy().to_string(); + if !is_wsl() { + return value; + } + if let Some(mapped) = win_path_to_wsl(&value) { + return mapped; + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn win_to_wsl_basic() { + assert_eq!( + win_path_to_wsl(r"C:\Temp\codex.zip").as_deref(), + Some("/mnt/c/Temp/codex.zip") + ); + assert_eq!( + win_path_to_wsl("D:/Work/codex.tgz").as_deref(), + Some("/mnt/d/Work/codex.tgz") + ); + assert!(win_path_to_wsl("/home/user/codex").is_none()); + } + + #[test] + fn normalize_is_noop_on_unix_paths() { + assert_eq!(normalize_for_wsl("/home/u/x"), "/home/u/x"); + } +} From db408b9e6239d13431e5f9993777c656f7cd94f7 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 7 Nov 2025 15:52:20 -0800 Subject: [PATCH 075/122] [App-server] add initialization to doc (#6377) Address comments in #6353. --- codex-rs/app-server/README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8f88d79e72..b2d6c25e8b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -14,6 +14,20 @@ Currently, you can dump a TypeScript version of the schema using `codex generate codex generate-ts --out DIR ``` +## Initialization + +Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error. + +Example: + +```json +{ "method": "initialize", "id": 0, "params": { + "clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" } +} } +{ "id": 0, "result": { "userAgent": "codex-app-server/0.1.0 codex-vscode/0.1.0" } } +{ "method": "initialized" } +``` + ## Core primitives We have 3 top level primitives: @@ -165,8 +179,8 @@ Request: Response examples: ```json -{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // no auth needed -{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // auth needed +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models) +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) { "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } ``` From 9fba811764c77c695cab76ffe425e5f118d1c59d Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 7 Nov 2025 15:54:07 -0800 Subject: [PATCH 076/122] refactor(terminal): cleanup deprecated flush logic (#6373) Removes flush logic that was leftover to test against ratatui's flush Cleaned up the flush logic so it's a bit more intent revealing. DrawCommand now owns the Cells that it draws as this works around a borrow checker problem. --- codex-rs/Cargo.lock | 13 ++++- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/custom_terminal.rs | 74 ++++++++++++++--------------- 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e8ce0bfe6a..a7f73e9c91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1455,6 +1455,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "crossterm", + "derive_more 2.0.1", "diffy", "dirs", "dunce", @@ -1658,6 +1659,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2003,7 +2013,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "convert_case", + "convert_case 0.6.0", "proc-macro2", "quote", "syn 2.0.104", @@ -2016,6 +2026,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ + "convert_case 0.7.1", "proc-macro2", "quote", "syn 2.0.104", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index ac00e9054b..b524d8bfd4 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-ollama = { workspace = true } codex-protocol = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +derive_more = { workspace = true, features = ["is_variant"] } diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index bd37fe8155..46d16a83f0 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -33,6 +33,7 @@ use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; use crossterm::terminal::Clear; +use derive_more::IsVariant; use ratatui::backend::Backend; use ratatui::backend::ClearType; use ratatui::buffer::Buffer; @@ -120,8 +121,6 @@ where /// Last known position of the cursor. Used to find the new area when the viewport is inlined /// and the terminal resized. pub last_known_cursor_pos: Position, - - use_custom_flush: bool, } impl Drop for Terminal @@ -151,16 +150,12 @@ where let cursor_pos = backend.get_cursor_position()?; Ok(Self { backend, - buffers: [ - Buffer::empty(Rect::new(0, 0, 0, 0)), - Buffer::empty(Rect::new(0, 0, 0, 0)), - ], + buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], current: 0, hidden_cursor: false, viewport_area: Rect::new(0, cursor_pos.y, 0, 0), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, - use_custom_flush: true, }) } @@ -173,11 +168,26 @@ where } } + /// Gets the current buffer as a reference. + fn current_buffer(&self) -> &Buffer { + &self.buffers[self.current] + } + /// Gets the current buffer as a mutable reference. - pub fn current_buffer_mut(&mut self) -> &mut Buffer { + fn current_buffer_mut(&mut self) -> &mut Buffer { &mut self.buffers[self.current] } + /// Gets the previous buffer as a reference. + fn previous_buffer(&self) -> &Buffer { + &self.buffers[1 - self.current] + } + + /// Gets the previous buffer as a mutable reference. + fn previous_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[1 - self.current] + } + /// Gets the backend pub const fn backend(&self) -> &B { &self.backend @@ -191,26 +201,12 @@ where /// Obtains a difference between the previous and the current buffer and passes it to the /// current backend for drawing. pub fn flush(&mut self) -> io::Result<()> { - let previous_buffer = &self.buffers[1 - self.current]; - let current_buffer = &self.buffers[self.current]; - - if self.use_custom_flush { - let updates = diff_buffers(previous_buffer, current_buffer); - if let Some(DrawCommand::Put { x, y, .. }) = updates - .iter() - .rev() - .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) - { - self.last_known_cursor_pos = Position { x: *x, y: *y }; - } - draw(&mut self.backend, updates.into_iter()) - } else { - let updates = previous_buffer.diff(current_buffer); - if let Some((x, y, _)) = updates.last() { - self.last_known_cursor_pos = Position { x: *x, y: *y }; - } - self.backend.draw(updates.into_iter()) + let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); + let last_put_command = updates.iter().rfind(|command| command.is_put()); + if let Some(&DrawCommand::Put { x, y, .. }) = last_put_command { + self.last_known_cursor_pos = Position { x, y }; } + draw(&mut self.backend, updates.into_iter()) } /// Updates the Terminal so that internal buffers match the requested area. @@ -224,8 +220,8 @@ where /// Sets the viewport area. pub fn set_viewport_area(&mut self, area: Rect) { - self.buffers[self.current].resize(area); - self.buffers[1 - self.current].resize(area); + self.current_buffer_mut().resize(area); + self.previous_buffer_mut().resize(area); self.viewport_area = area; } @@ -337,7 +333,7 @@ where self.swap_buffers(); - ratatui::backend::Backend::flush(&mut self.backend)?; + Backend::flush(&mut self.backend)?; Ok(()) } @@ -381,13 +377,13 @@ where .set_cursor_position(self.viewport_area.as_position())?; self.backend.clear_region(ClearType::AfterCursor)?; // Reset the back buffer to make sure the next update will redraw everything. - self.buffers[1 - self.current].reset(); + self.previous_buffer_mut().reset(); Ok(()) } /// Clears the inactive buffer and swaps it with the current buffer pub fn swap_buffers(&mut self) { - self.buffers[1 - self.current].reset(); + self.previous_buffer_mut().reset(); self.current = 1 - self.current; } @@ -400,13 +396,13 @@ where use ratatui::buffer::Cell; use unicode_width::UnicodeWidthStr; -#[derive(Debug)] -enum DrawCommand<'a> { - Put { x: u16, y: u16, cell: &'a Cell }, +#[derive(Debug, IsVariant)] +enum DrawCommand { + Put { x: u16, y: u16, cell: Cell }, ClearToEnd { x: u16, y: u16, bg: Color }, } -fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { +fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { let previous_buffer = &a.content; let next_buffer = &b.content; @@ -455,7 +451,7 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { updates.push(DrawCommand::Put { x, y, - cell: &next_buffer[i], + cell: next_buffer[i].clone(), }); } } @@ -468,9 +464,9 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { updates } -fn draw<'a, I>(writer: &mut impl Write, commands: I) -> io::Result<()> +fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> where - I: Iterator>, + I: Iterator, { let mut fg = Color::Reset; let mut bg = Color::Reset; From 183fc8e01a121125682c7224f39a8d91bb862cd2 Mon Sep 17 00:00:00 2001 From: Alexander Smirnov <145155732+smalyu@users.noreply.github.com> Date: Sat, 8 Nov 2025 02:55:16 +0300 Subject: [PATCH 077/122] core: replace Cloudflare 403 HTML with friendly message (#6252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation When Codex is launched from a region where Cloudflare blocks access (for example, Russia), the CLI currently dumps Cloudflare’s entire HTML error page. This isn’t actionable and makes it hard for users to understand what happened. We want to detect the Cloudflare block and show a concise, user-friendly explanation instead. ### What Changed - Added CLOUDFLARE_BLOCKED_MESSAGE and a friendly_message() helper to UnexpectedResponseError. Whenever we see a 403 whose body contains the Cloudflare block notice, we now emit a single-line message (Access blocked by Cloudflare…) while preserving the HTTP status and request id. All other responses keep the original behaviour. - Added two focused unit tests: - unexpected_status_cloudflare_html_is_simplified ensures the Cloudflare HTML case yields the friendly message. - unexpected_status_non_html_is_unchanged confirms plain-text 403s still return the raw body. ### Testing - cargo build -p codex-cli - cargo test -p codex-core - just fix -p codex-core - cargo test --all-features --------- Co-authored-by: Eric Traut --- codex-rs/core/src/error.rs | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 10e936f1a5..64ba8df848 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -238,18 +238,44 @@ pub struct UnexpectedResponseError { pub request_id: Option, } +const CLOUDFLARE_BLOCKED_MESSAGE: &str = + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; + +impl UnexpectedResponseError { + fn friendly_message(&self) -> Option { + if self.status != StatusCode::FORBIDDEN { + return None; + } + + if !self.body.contains("Cloudflare") || !self.body.contains("blocked") { + return None; + } + + let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {})", self.status); + if let Some(id) = &self.request_id { + message.push_str(&format!(", request id: {id}")); + } + + Some(message) + } +} + impl std::fmt::Display for UnexpectedResponseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "unexpected status {}: {}{}", - self.status, - self.body, - self.request_id - .as_ref() - .map(|id| format!(", request id: {id}")) - .unwrap_or_default() - ) + if let Some(friendly) = self.friendly_message() { + write!(f, "{friendly}") + } else { + write!( + f, + "unexpected status {}: {}{}", + self.status, + self.body, + self.request_id + .as_ref() + .map(|id| format!(", request id: {id}")) + .unwrap_or_default() + ) + } } } @@ -665,6 +691,35 @@ mod tests { }); } + #[test] + fn unexpected_status_cloudflare_html_is_simplified() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "Cloudflare error: Sorry, you have been blocked" + .to_string(), + request_id: Some("ray-id".to_string()), + }; + let status = StatusCode::FORBIDDEN.to_string(); + assert_eq!( + err.to_string(), + format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), request id: ray-id") + ); + } + + #[test] + fn unexpected_status_non_html_is_unchanged() { + let err = UnexpectedResponseError { + status: StatusCode::FORBIDDEN, + body: "plain text error".to_string(), + request_id: None, + }; + let status = StatusCode::FORBIDDEN.to_string(); + assert_eq!( + err.to_string(), + format!("unexpected status {status}: plain text error") + ); + } + #[test] fn usage_limit_reached_includes_hours_and_minutes() { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); From 91b16b8682c856d5b90cc4b24e7602e012f76f82 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 7 Nov 2025 16:36:04 -0800 Subject: [PATCH 078/122] Don't request approval for safe commands in unified exec (#6380) --- .../command_safety/is_dangerous_command.rs | 34 ++++ codex-rs/core/src/tools/runtimes/shell.rs | 31 +--- .../core/src/tools/runtimes/unified_exec.rs | 12 ++ codex-rs/core/tests/suite/approvals.rs | 166 ++++++++++++++---- 4 files changed, 181 insertions(+), 62 deletions(-) diff --git a/codex-rs/core/src/command_safety/is_dangerous_command.rs b/codex-rs/core/src/command_safety/is_dangerous_command.rs index b422513674..09594bb1c5 100644 --- a/codex-rs/core/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/core/src/command_safety/is_dangerous_command.rs @@ -1,4 +1,38 @@ +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; + use crate::bash::parse_shell_lc_plain_commands; +use crate::is_safe_command::is_known_safe_command; + +pub fn requires_initial_appoval( + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + command: &[String], + with_escalated_permissions: bool, +) -> bool { + if is_known_safe_command(command) { + return false; + } + match policy { + AskForApproval::Never | AskForApproval::OnFailure => false, + AskForApproval::OnRequest => { + // In DangerFullAccess, only prompt if the command looks dangerous. + if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + return command_might_be_dangerous(command); + } + + // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for + // non‑escalated, non‑dangerous commands — let the sandbox enforce + // restrictions (e.g., block network/write) without a user prompt. + let wants_escalation: bool = with_escalated_permissions; + if wants_escalation { + return true; + } + command_might_be_dangerous(command) + } + AskForApproval::UnlessTrusted => !is_known_safe_command(command), + } +} pub fn command_might_be_dangerous(command: &[String]) -> bool { if is_dangerous_to_call_with_exec(command) { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index f29224fcc1..bf7ae7fa3b 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -4,8 +4,7 @@ Runtime: shell Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ -use crate::command_safety::is_dangerous_command::command_might_be_dangerous; -use crate::command_safety::is_safe_command::is_known_safe_command; +use crate::command_safety::is_dangerous_command::requires_initial_appoval; use crate::exec::ExecToolCallOutput; use crate::protocol::SandboxPolicy; use crate::sandboxing::execute_env; @@ -121,28 +120,12 @@ impl Approvable for ShellRuntime { policy: AskForApproval, sandbox_policy: &SandboxPolicy, ) -> bool { - if is_known_safe_command(&req.command) { - return false; - } - match policy { - AskForApproval::Never | AskForApproval::OnFailure => false, - AskForApproval::OnRequest => { - // In DangerFullAccess, only prompt if the command looks dangerous. - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { - return command_might_be_dangerous(&req.command); - } - - // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for - // non‑escalated, non‑dangerous commands — let the sandbox enforce - // restrictions (e.g., block network/write) without a user prompt. - let wants_escalation = req.with_escalated_permissions.unwrap_or(false); - if wants_escalation { - return true; - } - command_might_be_dangerous(&req.command) - } - AskForApproval::UnlessTrusted => !is_known_safe_command(&req.command), - } + requires_initial_appoval( + policy, + sandbox_policy, + &req.command, + req.with_escalated_permissions.unwrap_or(false), + ) } fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 85c996387a..47157587bb 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -1,3 +1,4 @@ +use crate::command_safety::is_dangerous_command::requires_initial_appoval; /* Runtime: unified exec @@ -21,7 +22,9 @@ use crate::tools::sandboxing::with_cached_approval; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecSession; use crate::unified_exec::UnifiedExecSessionManager; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -106,6 +109,15 @@ impl Approvable for UnifiedExecRuntime<'_> { .await }) } + + fn wants_initial_approval( + &self, + req: &UnifiedExecRequest, + policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + ) -> bool { + requires_initial_appoval(policy, sandbox_policy, &req.command, false) + } } impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 0f54713b97..68407367ec 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; +use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::AskForApproval; @@ -24,6 +25,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use regex_lite::Regex; use serde_json::Value; use serde_json::json; use std::env; @@ -71,6 +73,9 @@ enum ActionKind { RunCommand { command: &'static [&'static str], }, + RunUnifiedExecCommand { + command: &'static str, + }, ApplyPatchFunction { target: TargetPath, content: &'static str, @@ -134,6 +139,17 @@ impl ActionKind { let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; Ok((event, Some(command))) } + ActionKind::RunUnifiedExecCommand { command } => { + let event = exec_command_event(call_id, command, Some(1000))?; + Ok(( + event, + Some(vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + command.to_string(), + ]), + )) + } ActionKind::ApplyPatchFunction { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); @@ -183,6 +199,17 @@ fn shell_event( Ok(ev_function_call(call_id, "shell", &args_str)) } +fn exec_command_event(call_id: &str, cmd: &str, yield_time_ms: Option) -> Result { + let mut args = json!({ + "cmd": cmd.to_string(), + }); + if let Some(yield_time_ms) = yield_time_ms { + args["yield_time_ms"] = json!(yield_time_ms); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + #[derive(Clone)] enum Expectation { FileCreated { @@ -206,6 +233,9 @@ enum Expectation { CommandSuccess { stdout_contains: &'static str, }, + CommandFailure { + output_contains: &'static str, + }, } impl Expectation { @@ -337,6 +367,19 @@ impl Expectation { result.stdout ); } + Expectation::CommandFailure { output_contains } => { + assert_ne!( + result.exit_code, + Some(0), + "expected non-zero exit for command failure: {}", + result.stdout + ); + assert!( + result.stdout.contains(output_contains), + "command failure stderr missing {output_contains:?}: {}", + result.stdout + ); + } } Ok(()) } @@ -362,7 +405,7 @@ struct ScenarioSpec { sandbox_policy: SandboxPolicy, action: ActionKind, with_escalated_permissions: bool, - requires_apply_patch_tool: bool, + features: Vec, model_override: Option<&'static str>, outcome: Outcome, expectation: Expectation, @@ -410,10 +453,24 @@ fn parse_result(item: &Value) -> CommandResult { let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); CommandResult { exit_code, stdout } } - Err(_) => CommandResult { - exit_code: None, - stdout: output_str.to_string(), - }, + Err(_) => { + let regex = + Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); + // parse freeform output + if let Some(captures) = regex.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + CommandResult { + exit_code: Some(exit_code), + stdout: output.to_string(), + } + } else { + CommandResult { + exit_code: None, + stdout: output_str.to_string(), + } + } + } } } @@ -506,7 +563,7 @@ fn scenarios() -> Vec { content: "danger-on-request", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileCreated { @@ -523,7 +580,7 @@ fn scenarios() -> Vec { response_body: "danger-network-ok", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::NetworkSuccess { @@ -538,7 +595,7 @@ fn scenarios() -> Vec { command: &["echo", "trusted-unless"], }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { @@ -554,7 +611,7 @@ fn scenarios() -> Vec { content: "danger-on-failure", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileCreated { @@ -571,7 +628,7 @@ fn scenarios() -> Vec { content: "danger-unless-trusted", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -591,7 +648,7 @@ fn scenarios() -> Vec { content: "danger-never", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileCreated { @@ -608,7 +665,7 @@ fn scenarios() -> Vec { content: "read-only-approval", }, with_escalated_permissions: true, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -627,7 +684,7 @@ fn scenarios() -> Vec { command: &["echo", "trusted-read-only"], }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { @@ -643,7 +700,7 @@ fn scenarios() -> Vec { response_body: "should-not-see", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, @@ -657,7 +714,7 @@ fn scenarios() -> Vec { content: "should-not-write", }, with_escalated_permissions: true, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Denied, @@ -678,7 +735,7 @@ fn scenarios() -> Vec { content: "read-only-on-failure", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -698,7 +755,7 @@ fn scenarios() -> Vec { response_body: "read-only-network-ok", }, with_escalated_permissions: true, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -717,7 +774,7 @@ fn scenarios() -> Vec { content: "shell-apply-patch", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: None, outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, @@ -737,7 +794,7 @@ fn scenarios() -> Vec { content: "function-apply-patch", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: Some("gpt-5-codex"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { @@ -754,7 +811,7 @@ fn scenarios() -> Vec { content: "function-patch-danger", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5-codex"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { @@ -771,7 +828,7 @@ fn scenarios() -> Vec { content: "function-patch-outside", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: Some("gpt-5-codex"), outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, @@ -791,7 +848,7 @@ fn scenarios() -> Vec { content: "function-patch-outside-denied", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: Some("gpt-5-codex"), outcome: Outcome::PatchApproval { decision: ReviewDecision::Denied, @@ -811,7 +868,7 @@ fn scenarios() -> Vec { content: "shell-patch-outside", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: None, outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, @@ -831,7 +888,7 @@ fn scenarios() -> Vec { content: "function-patch-unless-trusted", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: Some("gpt-5-codex"), outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, @@ -851,7 +908,7 @@ fn scenarios() -> Vec { content: "function-patch-never", }, with_escalated_permissions: false, - requires_apply_patch_tool: true, + features: vec![], model_override: Some("gpt-5-codex"), outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { @@ -870,7 +927,7 @@ fn scenarios() -> Vec { content: "read-only-unless-trusted", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -890,7 +947,7 @@ fn scenarios() -> Vec { content: "read-only-never", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { @@ -910,7 +967,7 @@ fn scenarios() -> Vec { command: &["echo", "trusted-never"], }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { @@ -926,7 +983,7 @@ fn scenarios() -> Vec { content: "workspace-on-request", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileCreated { @@ -943,7 +1000,7 @@ fn scenarios() -> Vec { response_body: "workspace-network-blocked", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, @@ -957,7 +1014,7 @@ fn scenarios() -> Vec { content: "workspace-on-request-outside", }, with_escalated_permissions: true, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -977,7 +1034,7 @@ fn scenarios() -> Vec { response_body: "workspace-network-ok", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::NetworkSuccess { @@ -994,7 +1051,7 @@ fn scenarios() -> Vec { content: "workspace-on-failure", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -1014,7 +1071,7 @@ fn scenarios() -> Vec { content: "workspace-unless-trusted", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, @@ -1034,7 +1091,7 @@ fn scenarios() -> Vec { content: "workspace-never", }, with_escalated_permissions: false, - requires_apply_patch_tool: false, + features: vec![], model_override: None, outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { @@ -1046,6 +1103,39 @@ fn scenarios() -> Vec { }, }, }, + ScenarioSpec { + name: "unified exec on request no approval for safe command", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::RunUnifiedExecCommand { + command: "echo \"hello unified exec\"", + }, + with_escalated_permissions: false, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::Auto, + expectation: Expectation::CommandSuccess { + stdout_contains: "hello unified exec", + }, + }, + ScenarioSpec { + name: "unified exec on request requires approval unless trusted", + approval_policy: AskForApproval::UnlessTrusted, + sandbox_policy: SandboxPolicy::DangerFullAccess, + action: ActionKind::RunUnifiedExecCommand { + command: "git reset --hard", + }, + with_escalated_permissions: false, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Denied, + expected_reason: None, + }, + expectation: Expectation::CommandFailure { + output_contains: "rejected by user", + }, + }, ] } @@ -1065,7 +1155,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let server = start_mock_server().await; let approval_policy = scenario.approval_policy; let sandbox_policy = scenario.sandbox_policy.clone(); - let requires_apply_patch_tool = scenario.requires_apply_patch_tool; + let features = scenario.features.clone(); let model_override = scenario.model_override; let mut builder = test_codex().with_config(move |config| { @@ -1075,8 +1165,8 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { config.model = model.to_string(); config.model_family = find_family_for_model(model).expect("model should map to a known family"); - if requires_apply_patch_tool { - config.include_apply_patch_tool = true; + for feature in features { + config.features.enable(feature); } }); let test = builder.build(&server).await?; From a2fdfce02a870773b3b037954b0fad6222020389 Mon Sep 17 00:00:00 2001 From: Luca King Date: Fri, 7 Nov 2025 19:54:35 -0600 Subject: [PATCH 079/122] Kill shell tool process groups on timeout (#5258) ## Summary - launch shell tool processes in their own process group so Codex owns the full tree - on timeout or ctrl-c, send SIGKILL to the process group before terminating the tracked child - document that the default shell/unified_exec timeout remains 1000 ms ## Original Bug Long-lived shell tool commands hang indefinitely because the timeout handler only terminated the direct child process; any grandchildren it spawned kept running and held the PTY open, preventing Codex from regaining control. ## Repro Original Bug Install next.js and run `next dev` (which is a long-running shell process with children). On openai:main, it will cause the agent to permanently get stuck here until human intervention. On this branch, this command will be terminated successfully after timeout_ms which will unblock the agent. This is a critical fix for unmonitored / lightly monitored agents that don't have immediate human observation to unblock them. --------- Co-authored-by: Michael Bolin Co-authored-by: Michael Bolin --- codex-rs/core/src/exec.rs | 81 ++++++++++++++++++++++++++++++++++++++ codex-rs/core/src/spawn.rs | 32 +++++++++------ 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 31a611775a..012fde953b 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -518,6 +518,7 @@ async fn consume_truncated_output( } Err(_) => { // timeout + kill_child_process_group(&mut child)?; child.start_kill()?; // Debatable whether `child.wait().await` should be called here. (synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true) @@ -525,6 +526,7 @@ async fn consume_truncated_output( } } _ = tokio::signal::ctrl_c() => { + kill_child_process_group(&mut child)?; child.start_kill()?; (synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + SIGKILL_CODE), false) } @@ -621,6 +623,38 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { std::process::ExitStatus::from_raw(code as u32) } +#[cfg(unix)] +fn kill_child_process_group(child: &mut Child) -> io::Result<()> { + use std::io::ErrorKind; + + if let Some(pid) = child.id() { + let pid = pid as libc::pid_t; + let pgid = unsafe { libc::getpgid(pid) }; + if pgid == -1 { + let err = std::io::Error::last_os_error(); + if err.kind() != ErrorKind::NotFound { + return Err(err); + } + return Ok(()); + } + + let result = unsafe { libc::killpg(pgid, libc::SIGKILL) }; + if result == -1 { + let err = std::io::Error::last_os_error(); + if err.kind() != ErrorKind::NotFound { + return Err(err); + } + } + } + + Ok(()) +} + +#[cfg(not(unix))] +fn kill_child_process_group(_: &mut Child) -> io::Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -693,4 +727,51 @@ mod tests { let output = make_exec_output(exit_code, "", "", ""); assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); } + + #[cfg(unix)] + #[tokio::test] + async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> { + let command = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + "sleep 60 & echo $!; sleep 60".to_string(), + ]; + let env: HashMap = std::env::vars().collect(); + let params = ExecParams { + command, + cwd: std::env::current_dir()?, + timeout_ms: Some(500), + env, + with_escalated_permissions: None, + justification: None, + arg0: None, + }; + + let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?; + assert!(output.timed_out); + + let stdout = output.stdout.from_utf8_lossy().text; + let pid_line = stdout.lines().next().unwrap_or("").trim(); + let pid: i32 = pid_line.parse().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse pid from stdout '{pid_line}': {error}"), + ) + })?; + + let mut killed = false; + for _ in 0..20 { + // Use kill(pid, 0) to check if the process is alive. + if unsafe { libc::kill(pid, 0) } == -1 + && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() + { + killed = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + assert!(killed, "grandchild process with pid {pid} is still alive"); + Ok(()) + } } diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index c3a912664b..d738f12232 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -64,24 +64,32 @@ pub(crate) async fn spawn_child_async( // any child processes that were spawned as part of a `"shell"` tool call // to also be terminated. - // This relies on prctl(2), so it only works on Linux. - #[cfg(target_os = "linux")] + #[cfg(unix)] unsafe { + #[cfg(target_os = "linux")] let parent_pid = libc::getpid(); cmd.pre_exec(move || { - // This prctl call effectively requests, "deliver SIGTERM when my - // current parent dies." - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + if libc::setpgid(0, 0) == -1 { return Err(std::io::Error::last_os_error()); } - // Though if there was a race condition and this pre_exec() block is - // run _after_ the parent (i.e., the Codex process) has already - // exited, then parent will be the closest configured "subreaper" - // ancestor process, or PID 1 (init). If the Codex process has exited - // already, so should the child process. - if libc::getppid() != parent_pid { - libc::raise(libc::SIGTERM); + // This relies on prctl(2), so it only works on Linux. + #[cfg(target_os = "linux")] + { + // This prctl call effectively requests, "deliver SIGTERM when my + // current parent dies." + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(std::io::Error::last_os_error()); + } + + // Though if there was a race condition and this pre_exec() block is + // run _after_ the parent (i.e., the Codex process) has already + // exited, then parent will be the closest configured "subreaper" + // ancestor process, or PID 1 (init). If the Codex process has exited + // already, so should the child process. + if libc::getppid() != parent_pid { + libc::raise(libc::SIGTERM); + } } Ok(()) }); From 917f39ec12ddc30e5cd093364c4532901fdc604c Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 7 Nov 2025 21:28:55 -0800 Subject: [PATCH 080/122] Improve world-writable scan (#6381) 1. scan many more directories since it's much faster than the original implementation 2. limit overall scan time to 2s 3. skip some directories that are noisy - ApplicationData, Installer, etc. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/debug_sandbox.rs | 2 + codex-rs/tui/src/app.rs | 15 +++++-- codex-rs/tui/src/chatwidget.rs | 6 ++- codex-rs/windows-sandbox-rs/Cargo.toml | 1 + codex-rs/windows-sandbox-rs/src/audit.rs | 57 +++++++++++++++++------- codex-rs/windows-sandbox-rs/src/lib.rs | 4 +- 7 files changed, 66 insertions(+), 20 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a7f73e9c91..98ff97cde3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1566,6 +1566,7 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs-next", + "dunce", "rand 0.8.5", "serde", "serde_json", diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 6e7cd07701..cbb66af815 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -136,6 +136,8 @@ async fn run_command_under_sandbox( let env_map = env.clone(); let command_vec = command.clone(); let base_dir = config.codex_home.clone(); + + // Preflight audit is invoked elsewhere at the appropriate times. let res = tokio::task::spawn_blocking(move || { run_windows_sandbox_capture( policy_str, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fbf3d2a547..d992fcccd9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -191,7 +191,8 @@ impl App { let cwd = app.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); - Self::spawn_world_writable_scan(cwd, env_map, tx, false); + let logs_base_dir = app.config.codex_home.clone(); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx, false); } } @@ -472,7 +473,8 @@ impl App { let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = self.app_event_tx.clone(); - Self::spawn_world_writable_scan(cwd, env_map, tx, false); + let logs_base_dir = self.config.codex_home.clone(); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx, false); } } } @@ -624,11 +626,18 @@ impl App { fn spawn_world_writable_scan( cwd: PathBuf, env_map: std::collections::HashMap, + logs_base_dir: PathBuf, tx: AppEventSender, apply_preset_on_continue: bool, ) { tokio::task::spawn_blocking(move || { - if codex_windows_sandbox::preflight_audit_everyone_writable(&cwd, &env_map).is_err() { + if codex_windows_sandbox::preflight_audit_everyone_writable( + &cwd, + &env_map, + Some(logs_base_dir.as_path()), + ) + .is_err() + { if apply_preset_on_continue { if let Some(preset) = codex_common::approval_presets::builtin_approval_presets() .into_iter() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 30f9b66f00..0e0ff4c5ab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2106,7 +2106,11 @@ impl ChatWidget { for (k, v) in std::env::vars() { env_map.insert(k, v); } - match codex_windows_sandbox::preflight_audit_everyone_writable(&self.config.cwd, &env_map) { + match codex_windows_sandbox::preflight_audit_everyone_writable( + &self.config.cwd, + &env_map, + Some(self.config.codex_home.as_path()), + ) { Ok(()) => false, Err(_) => true, } diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index ab231530e5..ba39bbe0fc 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" anyhow = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +dunce = "1.0" [dependencies.rand] version = "0.8" default-features = false diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 71dceaa532..b0a55da0ae 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -38,6 +38,22 @@ use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; use windows_sys::Win32::Security::ACE_HEADER; use windows_sys::Win32::Security::EqualSid; +// Preflight scan limits +const MAX_ITEMS_PER_DIR: i32 = 1000; +const AUDIT_TIME_LIMIT_SECS: i64 = 2; +const MAX_CHECKED_LIMIT: i32 = 50000; +// Case-insensitive suffixes (normalized to forward slashes) to skip during one-level child scan +const SKIP_DIR_SUFFIXES: &[&str] = &[ + "/windows/installer", + "/windows/registration", + "/programdata", +]; + +fn normalize_path_key(p: &Path) -> String { + let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + n.to_string_lossy().replace('\\', "/").to_ascii_lowercase() +} + fn unique_push(set: &mut HashSet, out: &mut Vec, p: PathBuf) { if let Ok(abs) = p.canonicalize() { if set.insert(abs.clone()) { @@ -77,11 +93,7 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap } } // 5) Core system roots last - for p in [ - PathBuf::from("C:/"), - PathBuf::from("C:/Windows"), - PathBuf::from("C:/ProgramData"), - ] { + for p in [PathBuf::from("C:/"), PathBuf::from("C:/Windows")] { unique_push(&mut set, &mut out, p); } out @@ -164,14 +176,18 @@ unsafe fn path_has_world_write_allow(path: &Path) -> Result { pub fn audit_everyone_writable( cwd: &Path, env: &std::collections::HashMap, + logs_base_dir: Option<&Path>, ) -> Result<()> { let start = Instant::now(); let mut flagged: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); let mut checked = 0usize; // Fast path: check CWD immediate children first so workspace issues are caught early. if let Ok(read) = std::fs::read_dir(cwd) { - for ent in read.flatten().take(250) { - if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { + if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) + || checked > MAX_CHECKED_LIMIT as usize + { break; } let ft = match ent.file_type() { @@ -185,26 +201,32 @@ pub fn audit_everyone_writable( checked += 1; let has = unsafe { path_has_world_write_allow(&p)? }; if has { - flagged.push(p); + let key = normalize_path_key(&p); + if seen.insert(key) { flagged.push(p); } } } } // Continue with broader candidate sweep let candidates = gather_candidates(cwd, env); for root in candidates { - if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) + || checked > MAX_CHECKED_LIMIT as usize + { break; } checked += 1; let has_root = unsafe { path_has_world_write_allow(&root)? }; if has_root { - flagged.push(root.clone()); + let key = normalize_path_key(&root); + if seen.insert(key) { flagged.push(root.clone()); } } // one level down best-effort if let Ok(read) = std::fs::read_dir(&root) { - for ent in read.flatten().take(250) { + for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) { let p = ent.path(); - if start.elapsed() > Duration::from_secs(5) || checked > 5000 { + if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64) + || checked > MAX_CHECKED_LIMIT as usize + { break; } // Skip reparse points (symlinks/junctions) to avoid auditing link ACLs @@ -215,11 +237,16 @@ pub fn audit_everyone_writable( if ft.is_symlink() { continue; } + // Skip noisy/irrelevant Windows system subdirectories + let pl = p.to_string_lossy().to_ascii_lowercase(); + let norm = pl.replace('\\', "/"); + if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { continue; } if ft.is_dir() { checked += 1; let has_child = unsafe { path_has_world_write_allow(&p)? }; if has_child { - flagged.push(p); + let key = normalize_path_key(&p); + if seen.insert(key) { flagged.push(p); } } } } @@ -236,7 +263,7 @@ pub fn audit_everyone_writable( "AUDIT: world-writable scan FAILED; checked={checked}; duration_ms={elapsed_ms}; flagged:{}", list ), - Some(cwd), + logs_base_dir, ); let mut list_err = String::new(); for p in flagged { @@ -252,7 +279,7 @@ pub fn audit_everyone_writable( &format!( "AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}" ), - Some(cwd), + logs_base_dir, ); Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 5e165f42dd..561ae2c39c 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -171,8 +171,9 @@ mod windows_impl { pub fn preflight_audit_everyone_writable( cwd: &Path, env_map: &HashMap, + logs_base_dir: Option<&Path>, ) -> Result<()> { - audit::audit_everyone_writable(cwd, env_map) + audit::audit_everyone_writable(cwd, env_map, logs_base_dir) } pub fn run_windows_sandbox_capture( @@ -436,6 +437,7 @@ mod stub { pub fn preflight_audit_everyone_writable( _cwd: &Path, _env_map: &HashMap, + _logs_base_dir: Option<&Path>, ) -> Result<()> { bail!("Windows sandbox is only available on Windows") } From 5beb6167c800cdf0611edaed8cba07a2d51c5c01 Mon Sep 17 00:00:00 2001 From: "Raduan A." <36044389+0xRaduan@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:08:42 +0100 Subject: [PATCH 081/122] feat(tui): Display keyboard shortcuts inline for approval options (#5889) Shows single-key shortcuts (y, a, n) next to approval options to make them more discoverable. Previously these shortcuts worked but were hidden, making the feature hard to discover. Changes: - "Yes, proceed" now shows "y" shortcut - "Yes, and don't ask again" now shows "a" shortcut - "No, and tell Codex..." continues to show "esc" shortcut This improves UX by surfacing the quick keyboard shortcuts that were already functional but undiscoverable in the UI. --- Update: added parentheses for better visual clarity CleanShot 2025-11-05 at 11 47 07@2x --------- Co-authored-by: Claude Co-authored-by: Eric Traut --- codex-rs/tui/src/bottom_pane/approval_overlay.rs | 4 +++- .../tui/src/bottom_pane/selection_popup_common.rs | 7 +++++-- ...i__chatwidget__tests__approval_modal_exec.snap | 6 +++--- ...get__tests__approval_modal_exec_no_reason.snap | 6 +++--- ...__chatwidget__tests__approval_modal_patch.snap | 4 ++-- ...atwidget__tests__exec_approval_modal_exec.snap | 15 ++++++++------- ...__tests__status_widget_and_approval_modal.snap | 6 +++--- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index d1d434c052..ef709f0051 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -117,7 +117,9 @@ impl ApprovalOverlay { .iter() .map(|opt| SelectionItem { name: opt.label.clone(), - display_shortcut: opt.display_shortcut, + display_shortcut: opt + .display_shortcut + .or_else(|| opt.additional_shortcuts.first().copied()), dismiss_on_select: false, ..Default::default() }) diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 4246adf4b5..8042a75b28 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -3,6 +3,7 @@ use ratatui::layout::Rect; // Note: Table-based layout previously used Constraint; the manual renderer // below no longer requires it. use ratatui::style::Color; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -96,8 +97,9 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { let this_name_width = Line::from(name_spans.clone()).width(); let mut full_spans: Vec = name_spans; if let Some(display_shortcut) = row.display_shortcut { - full_spans.push(" ".into()); + full_spans.push(" (".into()); full_spans.push(display_shortcut.into()); + full_spans.push(")".into()); } if let Some(desc) = row.description.as_ref() { let gap = desc_col.saturating_sub(this_name_width); @@ -179,8 +181,9 @@ pub(crate) fn render_rows( ); if Some(i) == state.selected_idx { // Match previous behavior: cyan + bold for the selected row. + // Reset the style first to avoid inheriting dim from keyboard shortcuts. full_line.spans.iter_mut().for_each(|span| { - span.style = span.style.fg(Color::Cyan).bold(); + span.style = Style::default().fg(Color::Cyan).bold(); }); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index d0990fa93a..b84588e337 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -9,8 +9,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world -› 1. Yes, proceed - 2. Yes, and don't ask again for this command - 3. No, and tell Codex what to do differently esc +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for this command (a) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 3a557bf6c9..543d367d23 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -6,8 +6,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world -› 1. Yes, proceed - 2. Yes, and don't ask again for this command - 3. No, and tell Codex what to do differently esc +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for this command (a) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 96dde8fb93..ed18675ac3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -11,7 +11,7 @@ expression: terminal.backend().vt100().screen().contents() 1 +hello 2 +world -› 1. Yes, proceed - 2. No, and tell Codex what to do differently esc +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f0cf3a2d33..f986a927e5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 409 expression: "format!(\"{buf:?}\")" --- Buffer { @@ -15,9 +14,9 @@ Buffer { " ", " $ echo hello world ", " ", - "› 1. Yes, proceed ", - " 2. Yes, and don't ask again for this command ", - " 3. No, and tell Codex what to do differently esc ", + "› 1. Yes, proceed (y) ", + " 2. Yes, and don't ask again for this command (a) ", + " 3. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", ], @@ -30,9 +29,11 @@ Buffer { x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 17, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 086a3a8c7e..f98c807878 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -12,8 +12,8 @@ expression: terminal.backend() " " " $ echo 'hello world' " " " -"› 1. Yes, proceed " -" 2. Yes, and don't ask again for this command " -" 3. No, and tell Codex what to do differently esc " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for this command (a) " +" 3. No, and tell Codex what to do differently (esc) " " " " Press enter to confirm or esc to cancel " From a47181e471b6efe55e95f9858c913fc89a3a44fa Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Sat, 8 Nov 2025 11:35:43 -0800 Subject: [PATCH 082/122] more world-writable warning improvements (#6389) 3 improvements: 1. show up to 3 actual paths that are world-writable 2. do the scan/warning for Read-Only mode too, because it also applies there 3. remove the "Cancel" option since it doesn't always apply (like on startup) --- codex-rs/tui/src/app.rs | 79 ++++++++++----- codex-rs/tui/src/app_event.rs | 6 ++ codex-rs/tui/src/chatwidget.rs | 116 +++++++++++++++++------ codex-rs/windows-sandbox-rs/src/audit.rs | 14 +-- codex-rs/windows-sandbox-rs/src/lib.rs | 4 +- 5 files changed, 155 insertions(+), 64 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d992fcccd9..06c13ff16f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -174,13 +174,14 @@ impl App { skip_world_writable_scan_once: false, }; - // On startup, if Auto mode (workspace-write) is active, warn about world-writable dirs on Windows. + // On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] { let should_check = codex_core::get_platform_sandbox().is_some() && matches!( app.config.sandbox_policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly ) && !app .config @@ -192,7 +193,7 @@ impl App { let env_map: std::collections::HashMap = std::env::vars().collect(); let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx, false); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx); } } @@ -386,9 +387,18 @@ impl App { AppEvent::OpenFullAccessConfirmation { preset } => { self.chat_widget.open_full_access_confirmation(preset); } - AppEvent::OpenWorldWritableWarningConfirmation { preset } => { - self.chat_widget - .open_world_writable_warning_confirmation(preset); + AppEvent::OpenWorldWritableWarningConfirmation { + preset, + sample_paths, + extra_count, + failed_scan, + } => { + self.chat_widget.open_world_writable_warning_confirmation( + preset, + sample_paths, + extra_count, + failed_scan, + ); } AppEvent::OpenFeedbackNote { category, @@ -449,14 +459,15 @@ impl App { } AppEvent::UpdateSandboxPolicy(policy) => { #[cfg(target_os = "windows")] - let policy_is_workspace_write = matches!( + let policy_is_workspace_write_or_ro = matches!( policy, codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_core::protocol::SandboxPolicy::ReadOnly ); self.chat_widget.set_sandbox_policy(policy); - // If sandbox policy becomes workspace-write, run the Windows world-writable scan. + // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. @@ -466,7 +477,7 @@ impl App { } let should_check = codex_core::get_platform_sandbox().is_some() - && policy_is_workspace_write + && policy_is_workspace_write_or_ro && !self.chat_widget.world_writable_warning_hidden(); if should_check { let cwd = self.config.cwd.clone(); @@ -474,7 +485,7 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx, false); + Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx); } } } @@ -628,28 +639,48 @@ impl App { env_map: std::collections::HashMap, logs_base_dir: PathBuf, tx: AppEventSender, - apply_preset_on_continue: bool, ) { + #[inline] + fn normalize_windows_path_for_display(p: &std::path::Path) -> String { + let canon = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + canon.display().to_string().replace('/', "\\") + } tokio::task::spawn_blocking(move || { - if codex_windows_sandbox::preflight_audit_everyone_writable( + let result = codex_windows_sandbox::preflight_audit_everyone_writable( &cwd, &env_map, Some(logs_base_dir.as_path()), - ) - .is_err() + ); + if let Ok(ref paths) = result + && !paths.is_empty() { - if apply_preset_on_continue { - if let Some(preset) = codex_common::approval_presets::builtin_approval_presets() - .into_iter() - .find(|p| p.id == "auto") - { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset), - }); - } + let as_strings: Vec = paths + .iter() + .map(|p| normalize_windows_path_for_display(p)) + .collect(); + let sample_paths: Vec = as_strings.iter().take(3).cloned().collect(); + let extra_count = if as_strings.len() > sample_paths.len() { + as_strings.len() - sample_paths.len() } else { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None }); - } + 0 + }; + + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: None, + sample_paths, + extra_count, + failed_scan: false, + }); + } else if result.is_err() { + // Scan failed: still warn, but with no examples and mark as failed. + let sample_paths: Vec = Vec::new(); + let extra_count = 0usize; + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: None, + sample_paths, + extra_count, + failed_scan: true, + }); } }); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f14951a999..7068cb79d4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -79,6 +79,12 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWorldWritableWarningConfirmation { preset: Option, + /// Up to 3 sample world-writable directories to display in the warning. + sample_paths: Vec, + /// If there are more than `sample_paths`, this carries the remaining count. + extra_count: usize, + /// True when the scan failed (e.g. ACL query error) and protections could not be verified. + failed_scan: bool, }, /// Show Windows Subsystem for Linux setup instructions for auto mode. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0e0ff4c5ab..2a5eb276f4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2045,9 +2045,48 @@ impl ChatWidget { && self.windows_world_writable_flagged() { let preset_clone = preset.clone(); + // Compute sample paths for the warning popup. + let mut env_map: std::collections::HashMap = + std::collections::HashMap::new(); + for (k, v) in std::env::vars() { + env_map.insert(k, v); + } + let (sample_paths, extra_count, failed_scan) = + match codex_windows_sandbox::preflight_audit_everyone_writable( + &self.config.cwd, + &env_map, + Some(self.config.codex_home.as_path()), + ) { + Ok(paths) if !paths.is_empty() => { + fn normalize_windows_path_for_display( + p: &std::path::Path, + ) -> String { + let canon = dunce::canonicalize(p) + .unwrap_or_else(|_| p.to_path_buf()); + canon.display().to_string().replace('/', "\\") + } + let as_strings: Vec = paths + .iter() + .map(|p| normalize_windows_path_for_display(p)) + .collect(); + let samples: Vec = + as_strings.iter().take(3).cloned().collect(); + let extra = if as_strings.len() > samples.len() { + as_strings.len() - samples.len() + } else { + 0 + }; + (samples, extra, false) + } + Err(_) => (Vec::new(), 0, true), + _ => (Vec::new(), 0, false), + }; vec![Box::new(move |tx| { tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset_clone.clone()), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, }); })] } else { @@ -2111,7 +2150,7 @@ impl ChatWidget { &env_map, Some(self.config.codex_home.as_path()), ) { - Ok(()) => false, + Ok(paths) => !paths.is_empty(), Err(_) => true, } } @@ -2184,31 +2223,66 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, + sample_paths: Vec, + extra_count: usize, + failed_scan: bool, ) { let (approval, sandbox) = match &preset { Some(p) => (Some(p.approval), Some(p.sandbox.clone())), None => (None, None), }; let mut header_children: Vec> = Vec::new(); - let title_line = Line::from("Auto mode has unprotected directories").bold(); - let info_line = Line::from(vec![ - "Some important directories on this system are world-writable. ".into(), - "The Windows sandbox cannot protect writes to these locations in Auto mode." + let mode_label = match self.config.sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } => "Auto mode", + SandboxPolicy::ReadOnly => "Read-Only mode", + _ => "Auto mode", + }; + let title_line = Line::from("Unprotected directories found").bold(); + let info_line = if failed_scan { + Line::from(vec![ + "We couldn't complete the world-writable scan, so protections cannot be verified. " + .into(), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.") + .fg(Color::Red), + ]) + } else { + Line::from(vec![ + "Some important directories on this system are world-writable. ".into(), + format!( + "The Windows sandbox cannot protect writes to these locations in {mode_label}." + ) .fg(Color::Red), - ]); + ]) + }; header_children.push(Box::new(title_line)); header_children.push(Box::new( Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), )); + + if !sample_paths.is_empty() { + // Show up to three examples and optionally an "and X more" line. + let mut lines: Vec = Vec::new(); + lines.push(Line::from("Examples:").bold()); + for p in &sample_paths { + lines.push(Line::from(format!(" - {p}"))); + } + if extra_count > 0 { + lines.push(Line::from(format!("and {extra_count} more"))); + } + header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + } let header = ColumnRenderable::with(header_children); // Build actions ensuring acknowledgement happens before applying the new sandbox policy, // so downstream policy-change hooks don't re-trigger the warning. let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan once after user confirms continue. - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); + // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), + // to avoid duplicate warnings from the ensuing policy change. + if preset.is_some() { + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + } if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); } @@ -2222,36 +2296,21 @@ impl ChatWidget { accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); } - let deny_actions: Vec = if preset.is_some() { - vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })] - } else { - Vec::new() - }; - let items = vec![ SelectionItem { name: "Continue".to_string(), - description: Some("Apply Auto mode for this session".to_string()), + description: Some(format!("Apply {mode_label} for this session")), actions: accept_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Continue and don't warn again".to_string(), - description: Some("Enable Auto mode and remember this choice".to_string()), + description: Some(format!("Enable {mode_label} and remember this choice")), actions: accept_and_remember_actions, dismiss_on_select: true, ..Default::default() }, - SelectionItem { - name: "Cancel".to_string(), - description: Some("Go back without enabling Auto mode".to_string()), - actions: deny_actions, - dismiss_on_select: true, - ..Default::default() - }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -2266,6 +2325,9 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, + _sample_paths: Vec, + _extra_count: usize, + _failed_scan: bool, ) { } diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index b0a55da0ae..4cd19dea62 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,6 +1,5 @@ use crate::token::world_sid; use crate::winutil::to_wide; -use anyhow::anyhow; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; @@ -177,7 +176,7 @@ pub fn audit_everyone_writable( cwd: &Path, env: &std::collections::HashMap, logs_base_dir: Option<&Path>, -) -> Result<()> { +) -> Result> { let start = Instant::now(); let mut flagged: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -265,14 +264,7 @@ pub fn audit_everyone_writable( ), logs_base_dir, ); - let mut list_err = String::new(); - for p in flagged { - list_err.push_str(&format!("\n - {}", p.display())); - } - return Err(anyhow!( - "Refusing to run: found directories writable by Everyone: {}", - list_err - )); + return Ok(flagged); } // Log success once if nothing flagged crate::logging::log_note( @@ -281,7 +273,7 @@ pub fn audit_everyone_writable( ), logs_base_dir, ); - Ok(()) + Ok(Vec::new()) } // Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for // Everyone that includes generic or specific write bits? Skips inherit-only diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 561ae2c39c..955f2ca3ce 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -172,7 +172,7 @@ mod windows_impl { cwd: &Path, env_map: &HashMap, logs_base_dir: Option<&Path>, - ) -> Result<()> { + ) -> Result> { audit::audit_everyone_writable(cwd, env_map, logs_base_dir) } @@ -438,7 +438,7 @@ mod stub { _cwd: &Path, _env_map: &HashMap, _logs_base_dir: Option<&Path>, - ) -> Result<()> { + ) -> Result> { bail!("Windows sandbox is only available on Windows") } From 8b80a0a269926f237cd7827b70ccd505ec0ef242 Mon Sep 17 00:00:00 2001 From: "Raduan A." <36044389+0xRaduan@users.noreply.github.com> Date: Sun, 9 Nov 2025 17:37:16 +0100 Subject: [PATCH 083/122] Fix SDK documentation: replace 'file diffs' with 'file change notifications' (#6425) The TypeScript SDK's README incorrectly claimed that runStreamed() emits "file diffs". However, the FileChangeItem type only contains metadata (path, kind, status) without actual diff content. Updated line 36 to accurately describe the SDK as providing "file change notifications" instead of "file diffs" to match the actual implementation in items.ts. Fixes #5850 --- sdk/typescript/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index df36892337..967beed116 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -33,7 +33,7 @@ const nextTurn = await thread.run("Implement the fix"); ### Streaming responses -`run()` buffers events until the turn finishes. To react to intermediate progress—tool calls, streaming responses, and file diffs—use `runStreamed()` instead, which returns an async generator of structured events. +`run()` buffers events until the turn finishes. To react to intermediate progress—tool calls, streaming responses, and file change notifications—use `runStreamed()` instead, which returns an async generator of structured events. ```typescript const { events } = await thread.runStreamed("Diagnose the test failure and propose a fix"); From c07461e6f3bfdfa9b28e32233bfd06a7bb316c90 Mon Sep 17 00:00:00 2001 From: Oliver Mannion <125105+tekumara@users.noreply.github.com> Date: Mon, 10 Nov 2025 03:53:36 +1100 Subject: [PATCH 084/122] fix(seatbelt): Allow reading hw.physicalcpu (#6421) Allow reading `hw.physicalcpu` so numpy can be imported when running in the sandbox. resolves #6420 --- codex-rs/core/src/seatbelt_base_policy.sbpl | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index dccd7e2bfa..824e028033 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -49,6 +49,7 @@ (sysctl-name "hw.packages") (sysctl-name "hw.pagesize_compat") (sysctl-name "hw.pagesize") + (sysctl-name "hw.physicalcpu") (sysctl-name "hw.physicalcpu_max") (sysctl-name "hw.tbfrequency_compat") (sysctl-name "hw.vectorunit") From 5f1fab0e7c2c2ecbd3a50d2aeb164b3862dd03ff Mon Sep 17 00:00:00 2001 From: kinopeee <119112484+kinopeee@users.noreply.github.com> Date: Mon, 10 Nov 2025 09:35:08 +0900 Subject: [PATCH 085/122] fix(cloud-tasks): respect cli_auth_credentials_store config (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `codex cloud` always instantiated `AuthManager` with `File` mode, ignoring the user's actual `cli_auth_credentials_store` setting. This caused users with `cli_auth_credentials_store = "keyring"` (or `"auto"`) to see "Not signed in" errors even when they had valid credentials stored in the system keyring. ## Root cause The code called `Config::load_from_base_config_with_overrides()` with an empty `ConfigToml::default()`, which always returned `File` as the default store mode instead of loading the actual user configuration. ## Solution - **Added `util::load_cli_auth_manager()` helper** Properly loads user config via `load_config_as_toml_with_cli_overrides()` and extracts the `cli_auth_credentials_store` setting before creating `AuthManager`. - **Updated callers** - `init_backend()` - used when starting cloud tasks UI - `build_chatgpt_headers()` - used for API requests ## Testing - ✅ `just fmt` - ✅ `just fix -p codex-cloud-tasks` - ✅ `cargo test -p codex-cloud-tasks` ## Files changed - `codex-rs/cloud-tasks/src/lib.rs` - `codex-rs/cloud-tasks/src/util.rs` ## Verification Users with keyring-based auth can now run `codex cloud` successfully without "Not signed in" errors. --------- Co-authored-by: Eric Traut Co-authored-by: celia-oai --- codex-rs/cloud-tasks/src/lib.rs | 17 ++-------- codex-rs/cloud-tasks/src/util.rs | 55 ++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 8f8b3066c9..7954da5eb9 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -8,6 +8,7 @@ pub mod util; pub use cli::Cli; use anyhow::anyhow; +use codex_login::AuthManager; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; @@ -56,20 +57,8 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result }; append_error_log(format!("startup: base_url={base_url} path_style={style}")); - let auth = match codex_core::config::find_codex_home() - .ok() - .map(|home| { - let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( - codex_core::config::ConfigToml::default(), - codex_core::config::ConfigOverrides::default(), - home.clone(), - ) - .map(|cfg| cfg.cli_auth_credentials_store_mode) - .unwrap_or_default(); - codex_login::AuthManager::new(home, false, store_mode) - }) - .and_then(|am| am.auth()) - { + let auth_manager = util::load_auth_manager().await; + let auth = match auth_manager.as_ref().and_then(AuthManager::auth) { Some(auth) => auth, None => { eprintln!( diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index ff17265f3d..1c690b26c0 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -2,6 +2,10 @@ use base64::Engine as _; use chrono::Utc; use reqwest::header::HeaderMap; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_login::AuthManager; + pub fn set_user_agent_suffix(suffix: &str) { if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() { guard.replace(suffix.to_string()); @@ -54,6 +58,18 @@ pub fn extract_chatgpt_account_id(token: &str) -> Option { .map(str::to_string) } +pub async fn load_auth_manager() -> Option { + // TODO: pass in cli overrides once cloud tasks properly support them. + let config = Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default()) + .await + .ok()?; + Some(AuthManager::new( + config.codex_home, + false, + config.cli_auth_credentials_store_mode, + )) +} + /// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, /// and optional `ChatGPT-Account-Id`. pub async fn build_chatgpt_headers() -> HeaderMap { @@ -69,31 +85,22 @@ pub async fn build_chatgpt_headers() -> HeaderMap { USER_AGENT, HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); - if let Ok(home) = codex_core::config::find_codex_home() { - let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( - codex_core::config::ConfigToml::default(), - codex_core::config::ConfigOverrides::default(), - home.clone(), - ) - .map(|cfg| cfg.cli_auth_credentials_store_mode) - .unwrap_or_default(); - let am = codex_login::AuthManager::new(home, false, store_mode); - if let Some(auth) = am.auth() - && let Ok(tok) = auth.get_token().await - && !tok.is_empty() + if let Some(am) = load_auth_manager().await + && let Some(auth) = am.auth() + && let Ok(tok) = auth.get_token().await + && !tok.is_empty() + { + let v = format!("Bearer {tok}"); + if let Ok(hv) = HeaderValue::from_str(&v) { + headers.insert(AUTHORIZATION, hv); + } + if let Some(acc) = auth + .get_account_id() + .or_else(|| extract_chatgpt_account_id(&tok)) + && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") + && let Ok(hv) = HeaderValue::from_str(&acc) { - let v = format!("Bearer {tok}"); - if let Ok(hv) = HeaderValue::from_str(&v) { - headers.insert(AUTHORIZATION, hv); - } - if let Some(acc) = auth - .get_account_id() - .or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = HeaderValue::from_str(&acc) - { - headers.insert(name, hv); - } + headers.insert(name, hv); } } headers From 625f2208c4ecd6555db10d6c7076fa4bfa13f299 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Sun, 9 Nov 2025 21:07:44 -0800 Subject: [PATCH 086/122] For npm upgrade on Windows, go through cmd.exe to get path traversal working (#6387) On Windows, `npm` by itself does not resolve under std::process::Command which does not consider PATHEXT to resolve it to `npm.cmd` in the PATH. By running the npm upgrade command via cmd.exe we get proper path semantics so it actually works. --- codex-rs/cli/src/main.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d69eb5a10c..66f551a5bc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,10 +26,11 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; +#[cfg(not(windows))] mod wsl_paths; use crate::mcp_cmd::McpCli; -use crate::wsl_paths::normalize_for_wsl; + use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::features::is_known_feature_key; @@ -269,14 +270,30 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); - let (cmd, args) = action.command_args(); let cmd_str = action.command_str(); println!("Updating Codex via `{cmd_str}`..."); - let command_path = normalize_for_wsl(cmd); - let normalized_args: Vec = args.iter().map(normalize_for_wsl).collect(); - let status = std::process::Command::new(&command_path) - .args(&normalized_args) - .status()?; + + let status = { + #[cfg(windows)] + { + // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + .status()? + } + #[cfg(not(windows))] + { + let (cmd, args) = action.command_args(); + let command_path = crate::wsl_paths::normalize_for_wsl(cmd); + let normalized_args: Vec = args + .iter() + .map(crate::wsl_paths::normalize_for_wsl) + .collect(); + std::process::Command::new(&command_path) + .args(&normalized_args) + .status()? + } + }; if !status.success() { anyhow::bail!("`{cmd_str}` failed with status {status}"); } From 7c7c7567d5277a18d7a1270a475c7ab10b174fed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:58:51 -0800 Subject: [PATCH 087/122] chore(deps): bump codespell-project/actions-codespell from 2.1 to 2.2 (#6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [codespell-project/actions-codespell](https://github.com/codespell-project/actions-codespell) from 2.1 to 2.2.
Release notes

Sourced from codespell-project/actions-codespell's releases.

v2.2

What's Changed

New Contributors

Full Changelog: https://github.com/codespell-project/actions-codespell/compare/v2...v2.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=codespell-project/actions-codespell&package-manager=github_actions&previous-version=2.1&new-version=2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codespell.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index ad16769471..c036581325 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -22,6 +22,6 @@ jobs: - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - name: Codespell - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1 + uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: ignore_words_file: .codespellignore From 082d2fa19a4d20595f912e5014f146187ba3c305 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 22:00:08 -0800 Subject: [PATCH 088/122] chore(deps): bump taiki-e/install-action from 2.60.0 to 2.62.49 (#6438) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.60.0 to 2.62.49.
Release notes

Sourced from taiki-e/install-action's releases.

2.62.49

  • Update cargo-binstall@latest to 1.15.11.

  • Update cargo-auditable@latest to 0.7.2.

  • Update vacuum@latest to 0.20.2.

2.62.48

  • Update mise@latest to 2025.11.3.

  • Update cargo-audit@latest to 0.22.0.

  • Update vacuum@latest to 0.20.1.

  • Update uv@latest to 0.9.8.

  • Update cargo-udeps@latest to 0.1.60.

  • Update zizmor@latest to 1.16.3.

2.62.47

  • Update vacuum@latest to 0.20.0.

  • Update cargo-nextest@latest to 0.9.111.

  • Update cargo-shear@latest to 1.6.2.

2.62.46

  • Update vacuum@latest to 0.19.5.

  • Update syft@latest to 1.37.0.

  • Update mise@latest to 2025.11.2.

  • Update knope@latest to 0.21.5.

2.62.45

  • Update zizmor@latest to 1.16.2.

  • Update cargo-binstall@latest to 1.15.10.

  • Update ubi@latest to 0.8.4.

  • Update mise@latest to 2025.11.1.

  • Update cargo-semver-checks@latest to 0.45.0.

2.62.44

  • Update mise@latest to 2025.11.0.

... (truncated)

Changelog

Sourced from taiki-e/install-action's changelog.

Changelog

All notable changes to this project will be documented in this file.

This project adheres to Semantic Versioning.

[Unreleased]

[2.62.49] - 2025-11-09

  • Update cargo-binstall@latest to 1.15.11.

  • Update cargo-auditable@latest to 0.7.2.

  • Update vacuum@latest to 0.20.2.

[2.62.48] - 2025-11-08

  • Update mise@latest to 2025.11.3.

  • Update cargo-audit@latest to 0.22.0.

  • Update vacuum@latest to 0.20.1.

  • Update uv@latest to 0.9.8.

  • Update cargo-udeps@latest to 0.1.60.

  • Update zizmor@latest to 1.16.3.

[2.62.47] - 2025-11-05

  • Update vacuum@latest to 0.20.0.

  • Update cargo-nextest@latest to 0.9.111.

  • Update cargo-shear@latest to 1.6.2.

[2.62.46] - 2025-11-04

  • Update vacuum@latest to 0.19.5.

  • Update syft@latest to 1.37.0.

  • Update mise@latest to 2025.11.2.

... (truncated)

Commits
  • 44c6d64 Release 2.62.49
  • 3a701df Update cargo-binstall@latest to 1.15.11
  • 4242e04 Update cargo-auditable@latest to 0.7.2
  • 3df5533 Update vacuum@latest to 0.20.2
  • e797ba6 Release 2.62.48
  • bcf91e0 Update mise@latest to 2025.11.3
  • e78113b Update cargo-audit@latest to 0.22.0
  • 0ef4864 Update vacuum@latest to 0.20.1
  • 5eda7b1 Update uv@latest to 0.9.8
  • 3853a41 Update cargo-udeps@latest to 0.1.60
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=taiki-e/install-action&package-manager=github_actions&previous-version=2.60.0&new-version=2.62.49)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/rust-ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 103e8cf6bd..1a900bffd2 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -76,7 +76,7 @@ jobs: steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.90 - - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear version: 1.5.1 @@ -170,7 +170,7 @@ jobs: # Install and restore sccache cache - name: Install sccache - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: sccache version: 0.7.5 @@ -228,7 +228,7 @@ jobs: - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-chef version: 0.1.71 @@ -370,7 +370,7 @@ jobs: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - name: Install sccache - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: sccache version: 0.7.5 @@ -399,7 +399,7 @@ jobs: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}- sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: nextest version: 0.9.103 From 78b2aeea5571e817a8c7fd803589ab25a1581e95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 22:02:26 -0800 Subject: [PATCH 089/122] chore(deps): bump askama from 0.12.1 to 0.14.0 in /codex-rs (#6443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [askama](https://github.com/askama-rs/askama) from 0.12.1 to 0.14.0.
Release notes

Sourced from askama's releases.

v0.14.0

Added Features

Bug Fixes And Consistency

Further Changes

New Contributors

Full Changelog: https://github.com/askama-rs/askama/compare/v0.13.0...v0.14.0

v0.13.1

What's Changed

New Contributors

Full Changelog: https://github.com/askama-rs/askama/compare/v0.13.0...v0.13.1

v0.13.0 – Rinja is Askama, again!

With this release, the fork rinja got merged back into the main project. Please have a look at our blog post for more information about the split and the merge.

What's Changed

This release (v0.13.0), when compared to the last stable askama release (v0.12.1), consists of:

  • over 1000 commits
  • with changes in over 500 files
  • with over 40k additions and 8000 deletions

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=askama&package-manager=cargo&previous-version=0.12.1&new-version=0.14.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 49 +++++++++++++++------------------------------ codex-rs/Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 98ff97cde3..04605b1daf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -237,46 +237,44 @@ dependencies = [ [[package]] name = "askama" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ "askama_derive", - "askama_escape", - "humansize", - "num-traits", + "itoa", "percent-encoding", + "serde", + "serde_json", ] [[package]] name = "askama_derive" -version = "0.12.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ "askama_parser", "basic-toml", - "mime", - "mime_guess", + "memchr", "proc-macro2", "quote", + "rustc-hash 2.1.1", "serde", + "serde_derive", "syn 2.0.104", ] -[[package]] -name = "askama_escape" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" - [[package]] name = "askama_parser" -version = "0.2.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" dependencies = [ - "nom", + "memchr", + "serde", + "serde_derive", + "winnow", ] [[package]] @@ -2891,15 +2889,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - [[package]] name = "hyper" version = "1.7.0" @@ -3543,12 +3532,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libm" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" - [[package]] name = "libredox" version = "0.1.6" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 619dbb5658..51a27754bf 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -97,7 +97,7 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" -askama = "0.12" +askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" From e2598f509405dc0c388bc483557064bc54cbde68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 22:03:36 -0800 Subject: [PATCH 090/122] chore(deps): bump zeroize from 1.8.1 to 1.8.2 in /codex-rs (#6444) Bumps [zeroize](https://github.com/RustCrypto/utils) from 1.8.1 to 1.8.2.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=zeroize&package-manager=cargo&previous-version=1.8.1&new-version=1.8.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 04605b1daf..a72c885868 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -7787,9 +7787,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 51a27754bf..d732151c85 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -213,7 +213,7 @@ which = "6" wildmatch = "2.5.0" wiremock = "0.6" -zeroize = "1.8.1" +zeroize = "1.8.2" [workspace.lints] rust = {} From 131c384361506c9d5e2976f896a9ed26d10dbcc5 Mon Sep 17 00:00:00 2001 From: Andrew Nikolin Date: Mon, 10 Nov 2025 01:12:28 -0500 Subject: [PATCH 091/122] Fix warning message phrasing (#6446) Small fix for sentence phrasing in the warning message Co-authored-by: AndrewNikolin <877163+AndrewNikolin@users.noreply.github.com> --- codex-rs/core/src/codex/compact.rs | 2 +- codex-rs/core/tests/suite/compact.rs | 2 +- codex-rs/exec/tests/event_processor_with_json_output.rs | 4 ++-- codex-rs/tui/src/chatwidget/tests.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index c07503663d..a3ffe6a3f0 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -164,7 +164,7 @@ async fn run_compact_task_inner( sess.send_event(&turn_context, event).await; let warning = EventMsg::Warning(WarningEvent { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), }); sess.send_event(&turn_context, warning).await; } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 72fe21841b..7b2fd6a217 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -54,7 +54,7 @@ const COMPACT_PROMPT_MARKER: &str = pub(super) const TEST_COMPACT_PROMPT: &str = "You are performing a CONTEXT CHECKPOINT COMPACTION for a tool.\nTest-only compact prompt."; -pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted."; +pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted."; fn auto_summary(summary: &str) -> String { summary.to_string() 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 4c1e0c77b5..5e2ef17dc1 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -548,7 +548,7 @@ fn warning_event_produces_error_item() { let out = ep.collect_thread_events(&event( "e1", EventMsg::Warning(WarningEvent { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), }), )); assert_eq!( @@ -557,7 +557,7 @@ fn warning_event_produces_error_item() { item: ThreadItem { id: "item_0".to_string(), details: ThreadItemDetails::Error(ErrorItem { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), }), }, })] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e035ce3bed..5bb89a329a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -60,7 +60,7 @@ use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; -const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted."; +const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted."; fn test_config() -> Config { // Use base defaults to avoid depending on host state. From 557ac630944aa13eb9fecb516a6fb732c09b75aa Mon Sep 17 00:00:00 2001 From: "Raduan A." <36044389+0xRaduan@users.noreply.github.com> Date: Mon, 10 Nov 2025 07:58:32 +0100 Subject: [PATCH 092/122] Fix config documentation: correct TOML parsing description (#6424) The CLI help text and inline comments incorrectly stated that -c key=value flag parses values as JSON, when the implementation actually uses TOML parsing via parse_toml_value(). This caused confusion when users attempted to configure MCP servers using JSON syntax based on the documentation. Changes: - Updated help text to correctly state TOML parsing instead of JSON Fixes #4531 --- codex-rs/common/src/config_override.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs index 6b77099524..597ec11cdc 100644 --- a/codex-rs/common/src/config_override.rs +++ b/codex-rs/common/src/config_override.rs @@ -19,8 +19,8 @@ use toml::Value; pub struct CliConfigOverrides { /// Override a configuration value that would otherwise be loaded from /// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override - /// nested values. The `value` portion is parsed as JSON. If it fails to - /// parse as JSON, the raw string is used as a literal. + /// nested values. The `value` portion is parsed as TOML. If it fails to + /// parse as TOML, the raw string is used as a literal. /// /// Examples: /// - `-c model="o3"` @@ -59,7 +59,7 @@ impl CliConfigOverrides { return Err(format!("Empty key in override: {s}")); } - // Attempt to parse as JSON. If that fails, treat it as a raw + // Attempt to parse as TOML. If that fails, treat it as a raw // string. This allows convenient usage such as // `-c model=o3` without the quotes. let value: Value = match parse_toml_value(value_str) { From 50a77dc138f36052471f3fdf4f09d00074fc9940 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 10 Nov 2025 11:59:48 +0000 Subject: [PATCH 093/122] Move compact (#6454) --- codex-rs/core/src/codex.rs | 23 +++++++++++++---------- codex-rs/core/src/{codex => }/compact.rs | 8 ++++---- codex-rs/core/src/lib.rs | 3 ++- codex-rs/core/src/tasks/compact.rs | 2 +- 4 files changed, 20 insertions(+), 16 deletions(-) rename codex-rs/core/src/{codex => }/compact.rs (98%) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fc6b44c621..d28f671f1b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6,6 +6,7 @@ use std::sync::atomic::AtomicU64; use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; +use crate::compact; use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::mcp::auth::McpAuthStatusEntry; @@ -66,6 +67,8 @@ use crate::error::Result as CodexResult; use crate::exec::StreamOutput; // Removed: legacy executor wiring replaced by ToolOrchestrator flows. // legacy normalize_exec_result no longer used after orchestrator migration +use crate::compact::build_compacted_history; +use crate::compact::collect_user_messages; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; @@ -129,10 +132,6 @@ use codex_protocol::user_input::UserInput; use codex_utils_readiness::Readiness; use codex_utils_readiness::ReadinessFlag; -pub mod compact; -use self::compact::build_compacted_history; -use self::compact::collect_user_messages; - /// The high-level interface to the Codex system. /// It operates as a queue pair where you send submissions and receive events. pub struct Codex { @@ -968,7 +967,7 @@ impl Session { } /// Append ResponseItems to the in-memory conversation history only. - async fn record_into_history(&self, items: &[ResponseItem]) { + pub(crate) async fn record_into_history(&self, items: &[ResponseItem]) { let mut state = self.state.lock().await; state.record_items(items.iter()); } @@ -1020,7 +1019,7 @@ impl Session { items } - async fn persist_rollout_items(&self, items: &[RolloutItem]) { + pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) { let recorder = { let guard = self.services.rollout.lock().await; guard.clone() @@ -1037,7 +1036,7 @@ impl Session { state.clone_history() } - async fn update_token_usage_info( + pub(crate) async fn update_token_usage_info( &self, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, @@ -1054,7 +1053,7 @@ impl Session { self.send_token_count_event(turn_context).await; } - async fn update_rate_limits( + pub(crate) async fn update_rate_limits( &self, turn_context: &TurnContext, new_rate_limits: RateLimitSnapshot, @@ -1075,7 +1074,7 @@ impl Session { self.send_event(turn_context, event).await; } - async fn set_total_tokens_full(&self, turn_context: &TurnContext) { + pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) { let context_window = turn_context.client.get_model_context_window(); if let Some(context_window) = context_window { { @@ -1118,7 +1117,11 @@ impl Session { self.send_event(turn_context, event).await; } - async fn notify_stream_error(&self, turn_context: &TurnContext, message: impl Into) { + pub(crate) async fn notify_stream_error( + &self, + turn_context: &TurnContext, + message: impl Into, + ) { let event = EventMsg::StreamError(StreamErrorEvent { message: message.into(), }); diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/compact.rs similarity index 98% rename from codex-rs/core/src/codex/compact.rs rename to codex-rs/core/src/compact.rs index a3ffe6a3f0..baee20f0f9 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,10 +1,10 @@ use std::sync::Arc; -use super::Session; -use super::TurnContext; -use super::get_last_assistant_message_from_turn; use crate::Prompt; use crate::client_common::ResponseEvent; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::codex::get_last_assistant_message_from_turn; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::AgentMessageEvent; @@ -25,7 +25,7 @@ use codex_protocol::user_input::UserInput; use futures::prelude::*; use tracing::error; -pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); +pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; pub(crate) async fn run_inline_auto_compact_task( diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 93a317700f..0738e7afa9 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,11 +99,12 @@ pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; -pub use codex::compact::content_items_to_text; pub use codex_protocol::models::ContentItem; pub use codex_protocol::models::LocalShellAction; pub use codex_protocol::models::LocalShellExecAction; pub use codex_protocol::models::LocalShellStatus; pub use codex_protocol::models::ResponseItem; +pub use compact::content_items_to_text; pub use event_mapping::parse_turn_item; +pub mod compact; pub mod otel_init; diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 64b2a9d2b4..4f06d68924 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; -use crate::codex::compact; +use crate::compact; use crate::state::TaskKind; use codex_protocol::user_input::UserInput; From 65cb1a1b7735db46a10265db7448f6c7b044e306 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 10 Nov 2025 09:57:56 -0600 Subject: [PATCH 094/122] Updated docs to reflect recent changes in `web_search` configuration (#6376) This is a simplified version of [a PR](https://github.com/openai/codex/pull/6134) supplied by a community member. It updates the docs to reflect a recent config deprecation. --- docs/config.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index 1175a986c4..90671ce98d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -347,11 +347,13 @@ Use the optional `[tools]` table to toggle built-in tools that the agent may cal ```toml [tools] -web_search = true # allow Codex to issue first-party web searches without prompting you +web_search = true # allow Codex to issue first-party web searches without prompting you (deprecated) view_image = false # disable image uploads (they're enabled by default) ``` -`web_search` is also recognized under the legacy name `web_search_request`. The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. +`web_search` is deprecated; use the `web_search_request` feature flag instead. + +The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow. ### approval_presets @@ -917,6 +919,7 @@ Valid values: | `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). | | `notify` | array | External program for notifications. | | `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | +| `features.` | boolean | See [feature flags](#feature-flags) for details | | `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | | `mcp_servers..args` | array | MCP server args (stdio servers only). | | `mcp_servers..env` | map | MCP server env vars (stdio servers only). | @@ -956,7 +959,7 @@ Valid values: | `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | -| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). | +| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). | | `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). | | `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. | | `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. | From 42683dadfb49fcd9b9d05ca47e42d6e1e098db29 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 10 Nov 2025 08:08:12 -0800 Subject: [PATCH 095/122] fix: use generate_ts from app_server_protocol (#6407) Update `codex generate-ts` to use the TS export code from `app-server-protocol/src/export.rs`. I realized there were two duplicate implementations of Typescript export code: - `app-server-protocol/src/export.rs` - the `codex-protocol-ts` crate The `codex-protocol-ts` crate that `codex generate-ts` uses is out of date now since it doesn't handle the V2 namespace from: https://github.com/openai/codex/pull/6212. --- codex-rs/Cargo.lock | 11 --- codex-rs/Cargo.toml | 2 - codex-rs/cli/Cargo.toml | 1 - codex-rs/cli/src/main.rs | 6 +- codex-rs/protocol-ts/Cargo.toml | 21 ----- codex-rs/protocol-ts/generate-ts | 10 --- codex-rs/protocol-ts/src/lib.rs | 135 ------------------------------- codex-rs/protocol-ts/src/main.rs | 20 ----- 8 files changed, 3 insertions(+), 203 deletions(-) delete mode 100644 codex-rs/protocol-ts/Cargo.toml delete mode 100755 codex-rs/protocol-ts/generate-ts delete mode 100644 codex-rs/protocol-ts/src/lib.rs delete mode 100644 codex-rs/protocol-ts/src/main.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a72c885868..ec6420a05f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -979,7 +979,6 @@ dependencies = [ "codex-mcp-server", "codex-process-hardening", "codex-protocol", - "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", "codex-stdio-to-uds", @@ -1363,16 +1362,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "codex-protocol-ts" -version = "0.0.0" -dependencies = [ - "anyhow", - "clap", - "codex-app-server-protocol", - "ts-rs", -] - [[package]] name = "codex-responses-api-proxy" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d732151c85..dd074e1de0 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -25,7 +25,6 @@ members = [ "ollama", "process-hardening", "protocol", - "protocol-ts", "rmcp-client", "responses-api-proxy", "stdio-to-uds", @@ -75,7 +74,6 @@ codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } -codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 041644b34d..c5a55bb2e2 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -30,7 +30,6 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } -codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 66f551a5bc..633f2605e7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -99,9 +99,9 @@ enum Subcommand { /// Resume a previous interactive session (picker by default; use --last to continue the most recent). Resume(ResumeCommand), - /// Internal: generate TypeScript protocol bindings. - #[clap(hide = true)] + /// [experimental] Generate TypeScript bindings for the app server protocol. GenerateTs(GenerateTsCommand), + /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -528,7 +528,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() .await??; } Some(Subcommand::GenerateTs(gen_cli)) => { - codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; + codex_app_server_protocol::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { diff --git a/codex-rs/protocol-ts/Cargo.toml b/codex-rs/protocol-ts/Cargo.toml deleted file mode 100644 index 53f314ccbe..0000000000 --- a/codex-rs/protocol-ts/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -edition = "2024" -name = "codex-protocol-ts" -version = { workspace = true } - -[lints] -workspace = true - -[lib] -name = "codex_protocol_ts" -path = "src/lib.rs" - -[[bin]] -name = "codex-protocol-ts" -path = "src/main.rs" - -[dependencies] -anyhow = { workspace = true } -clap = { workspace = true, features = ["derive"] } -codex-app-server-protocol = { workspace = true } -ts-rs = { workspace = true } diff --git a/codex-rs/protocol-ts/generate-ts b/codex-rs/protocol-ts/generate-ts deleted file mode 100755 index 8f90bced6b..0000000000 --- a/codex-rs/protocol-ts/generate-ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -cd "$(dirname "$0")"/.. - -tmpdir=$(mktemp -d) -just codex generate-ts --prettier ../node_modules/.bin/prettier --out "$tmpdir" - -echo "wrote output to $tmpdir" diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs deleted file mode 100644 index c3180d9b93..0000000000 --- a/codex-rs/protocol-ts/src/lib.rs +++ /dev/null @@ -1,135 +0,0 @@ -use anyhow::Context; -use anyhow::Result; -use anyhow::anyhow; -use codex_app_server_protocol::ClientNotification; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::export_client_responses; -use codex_app_server_protocol::export_server_responses; -use std::ffi::OsStr; -use std::fs; -use std::io::Read; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; -use ts_rs::TS; - -const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; - -pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { - ensure_dir(out_dir)?; - - // Generate the TS bindings client -> server messages. - ClientRequest::export_all_to(out_dir)?; - export_client_responses(out_dir)?; - ClientNotification::export_all_to(out_dir)?; - - // Generate the TS bindings server -> client messages. - ServerRequest::export_all_to(out_dir)?; - export_server_responses(out_dir)?; - ServerNotification::export_all_to(out_dir)?; - - // Generate index.ts that re-exports all types. - generate_index_ts(out_dir)?; - - // Prepend header to each generated .ts file - let ts_files = ts_files_in(out_dir)?; - for file in &ts_files { - prepend_header_if_missing(file)?; - } - - // Format with Prettier by passing individual files (no shell globbing) - if let Some(prettier_bin) = prettier - && !ts_files.is_empty() - { - let status = Command::new(prettier_bin) - .arg("--write") - .arg("--log-level") - .arg("warn") - .args(ts_files.iter().map(|p| p.as_os_str())) - .status() - .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; - if !status.success() { - return Err(anyhow!("Prettier failed with status {status}")); - } - } - - Ok(()) -} - -fn ensure_dir(dir: &Path) -> Result<()> { - fs::create_dir_all(dir) - .with_context(|| format!("Failed to create output directory {}", dir.display())) -} - -fn prepend_header_if_missing(path: &Path) -> Result<()> { - let mut content = String::new(); - { - let mut f = fs::File::open(path) - .with_context(|| format!("Failed to open {} for reading", path.display()))?; - f.read_to_string(&mut content) - .with_context(|| format!("Failed to read {}", path.display()))?; - } - - if content.starts_with(HEADER) { - return Ok(()); - } - - let mut f = fs::File::create(path) - .with_context(|| format!("Failed to open {} for writing", path.display()))?; - f.write_all(HEADER.as_bytes()) - .with_context(|| format!("Failed to write header to {}", path.display()))?; - f.write_all(content.as_bytes()) - .with_context(|| format!("Failed to write content to {}", path.display()))?; - Ok(()) -} - -fn ts_files_in(dir: &Path) -> Result> { - let mut files = Vec::new(); - for entry in - fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))? - { - let entry = entry?; - let path = entry.path(); - if path.is_file() && path.extension() == Some(OsStr::new("ts")) { - files.push(path); - } - } - files.sort(); - Ok(files) -} - -/// Generate an index.ts file that re-exports all generated types. -/// This allows consumers to import all types from a single file. -fn generate_index_ts(out_dir: &Path) -> Result { - let mut entries: Vec = Vec::new(); - let mut stems: Vec = ts_files_in(out_dir)? - .into_iter() - .filter_map(|p| { - let stem = p.file_stem()?.to_string_lossy().into_owned(); - if stem == "index" { None } else { Some(stem) } - }) - .collect(); - stems.sort(); - stems.dedup(); - - for name in stems { - entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); - } - - let mut content = - String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); - content.push_str(HEADER); - for line in &entries { - content.push_str(line); - } - - let index_path = out_dir.join("index.ts"); - let mut f = fs::File::create(&index_path) - .with_context(|| format!("Failed to create {}", index_path.display()))?; - f.write_all(content.as_bytes()) - .with_context(|| format!("Failed to write {}", index_path.display()))?; - Ok(index_path) -} diff --git a/codex-rs/protocol-ts/src/main.rs b/codex-rs/protocol-ts/src/main.rs deleted file mode 100644 index f477b9f5a6..0000000000 --- a/codex-rs/protocol-ts/src/main.rs +++ /dev/null @@ -1,20 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use std::path::PathBuf; - -#[derive(Parser, Debug)] -#[command(about = "Generate TypeScript bindings for the Codex protocol")] -struct Args { - /// Output directory where .ts files will be written - #[arg(short = 'o', long = "out", value_name = "DIR")] - out_dir: PathBuf, - - /// Optional path to the Prettier executable to format generated files - #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] - prettier: Option, -} - -fn main() -> Result<()> { - let args = Args::parse(); - codex_protocol_ts::generate_ts(&args.out_dir, args.prettier.as_deref()) -} From b46012e483825306de1968f88b8f95c2763f102e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 10 Nov 2025 10:43:11 -0600 Subject: [PATCH 096/122] Support exiting from the login menu (#6419) I recently fixed a bug in [this PR](https://github.com/openai/codex/pull/6285) that prevented Ctrl+C from dismissing the login menu in the TUI and leaving the user unauthed. A [user pointed out](https://github.com/openai/codex/issues/6418) that this makes Ctrl+C can no longer be used to exit the app. This PR changes the behavior so we exit the app rather than ignoring the Ctrl+C. --- codex-rs/tui/src/lib.rs | 10 ++++++++++ codex-rs/tui/src/onboarding/onboarding_screen.rs | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 99312ec198..9efa5eac5b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -353,6 +353,16 @@ async fn run_ratatui_app( &mut tui, ) .await?; + if onboarding_result.should_exit { + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } if onboarding_result.windows_install_selected { restore(); session_log::log_session_end(); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 6a1a9ff5d9..02c7be7ad2 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -57,6 +57,7 @@ pub(crate) struct OnboardingScreen { steps: Vec, is_done: bool, windows_install_selected: bool, + should_exit: bool, } pub(crate) struct OnboardingScreenArgs { @@ -71,6 +72,7 @@ pub(crate) struct OnboardingScreenArgs { pub(crate) struct OnboardingResult { pub directory_trust_decision: Option, pub windows_install_selected: bool, + pub should_exit: bool, } impl OnboardingScreen { @@ -137,6 +139,7 @@ impl OnboardingScreen { steps, is_done: false, windows_install_selected: false, + should_exit: false, } } @@ -200,6 +203,10 @@ impl OnboardingScreen { pub fn windows_install_selected(&self) -> bool { self.windows_install_selected } + + pub fn should_exit(&self) -> bool { + self.should_exit + } } impl KeyboardHandler for OnboardingScreen { @@ -222,9 +229,12 @@ impl KeyboardHandler for OnboardingScreen { kind: KeyEventKind::Press, .. } => { - if !self.is_auth_in_progress() { - self.is_done = true; + if self.is_auth_in_progress() { + // If the user cancels the auth menu, exit the app rather than + // leave the user at a prompt in an unauthed state. + self.should_exit = true; } + self.is_done = true; } _ => { if let Some(Step::Welcome(widget)) = self @@ -442,5 +452,6 @@ pub(crate) async fn run_onboarding_app( Ok(OnboardingResult { directory_trust_decision: onboarding_screen.directory_trust_decision(), windows_install_selected: onboarding_screen.windows_install_selected(), + should_exit: onboarding_screen.should_exit(), }) } From 59161531579842bd33fd79c18bc5d819f07a93b7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 10 Nov 2025 10:46:11 -0600 Subject: [PATCH 097/122] Don't lock PRs that have been closed without merging (#6422) The CLA action is designed to automatically lock a PR when it is closed. This preserves the CLA agreement statements, preventing the contributor from deleting them after the fact. However, this action is currently locking PRs that are closed without merging. I'd like to keep such PRs open so the contributor can respond with additional comments. I'm currently manually unlocking PRs that I close, but I'd like to eliminate this manual step. --- .github/workflows/cla.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 521e917e95..ec3a953cb8 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -16,10 +16,27 @@ jobs: runs-on: ubuntu-latest steps: - uses: contributor-assistant/github-action@v2.6.1 + # Run on close only if the PR was merged. This will lock the PR to preserve + # the CLA agreement. We don't want to lock PRs that have been closed without + # merging because the contributor may want to respond with additional comments. + # This action has a "lock-pullrequest-aftermerge" option that can be set to false, + # but that would unconditionally skip locking even in cases where the PR was merged. if: | - github.event_name == 'pull_request_target' || - github.event.comment.body == 'recheck' || - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' + ( + github.event_name == 'pull_request_target' && + ( + github.event.action == 'opened' || + github.event.action == 'synchronize' || + (github.event.action == 'closed' && github.event.pull_request.merged == true) + ) + ) || + ( + github.event_name == 'issue_comment' && + ( + github.event.comment.body == 'recheck' || + github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' + ) + ) env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From fbdedd9a069219a71a21a56b5695c34cb92cbd42 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 10 Nov 2025 08:59:14 -0800 Subject: [PATCH 098/122] [app-server] feat: add command to generate json schema (#6406) Add a `codex generate-json-schema` command for generating a JSON schema bundle of app-server types, analogous to the existing `codex generate-ts` command for Typescript. --- codex-rs/app-server-protocol/src/export.rs | 2 + codex-rs/app-server/README.md | 31 ++++++++----- codex-rs/cli/src/main.rs | 51 +++++++++++++++++----- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index c5038a00d3..4db011f721 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -666,6 +666,8 @@ fn ts_files_in_recursive(dir: &Path) -> Result> { Ok(files) } +/// Generate an index.ts file that re-exports all generated types. +/// This allows consumers to import all types from a single file. fn generate_index_ts(out_dir: &Path) -> Result { let mut entries: Vec = Vec::new(); let mut stems: Vec = ts_files_in(out_dir)? diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b2d6c25e8b..2efd52a0fb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -8,10 +8,11 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports ## Message Schema -Currently, you can dump a TypeScript version of the schema using `codex generate-ts`. It is specific to the version of Codex you used to run `generate-ts`, so the two are guaranteed to be compatible. +Currently, you can dump a TypeScript version of the schema using `codex app-server generate-ts`, or a JSON Schema bundle via `codex app-server generate-json-schema`. Each output is specific to the version of Codex you used to run the command, so the generated artifacts are guaranteed to match that version. ``` -codex generate-ts --out DIR +codex app-server generate-ts --out DIR +codex app-server generate-json-schema --out DIR ``` ## Initialization @@ -49,15 +50,16 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr ### 1) Start or resume a thread -Start a fresh thread when you need a new Codex conversation. Optional fields mirror CLI defaults: set `model`, `modelProvider`, `cwd`, `approvalPolicy`, `sandbox`, or custom `config` values. Instructions can be set via `baseInstructions` and `developerInstructions`: +Start a fresh thread when you need a new Codex conversation. ```json { "method": "thread/start", "id": 10, "params": { + // Optionally set config settings. If not specified, will use the user's + // current config settings. "model": "gpt-5-codex", "cwd": "/Users/me/project", "approvalPolicy": "never", - "sandbox": "workspace-write", - "baseInstructions": "You're helping with refactors." + "sandbox": "workspaceWrite", } } { "id": 10, "result": { "thread": { @@ -90,7 +92,6 @@ Example: { "method": "thread/list", "id": 20, "params": { "cursor": null, "limit": 25, - "modelProviders": ["openai"] } } { "id": 20, "result": { "data": [ @@ -122,18 +123,23 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio - `{"type":"image","url":"https://…png"}` - `{"type":"localImage","path":"/tmp/screenshot.png"}` -Override knobs apply to the new turn and become the defaults for subsequent turns on the same thread: +You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. ```json { "method": "turn/start", "id": 30, "params": { "threadId": "thr_123", "input": [ { "type": "text", "text": "Run tests" } ], + // Below are optional config overrides "cwd": "/Users/me/project", - "approvalPolicy": "untrusted", - "sandboxPolicy": "workspace-write", + "approvalPolicy": "unlessTrusted", + "sandboxPolicy": { + "mode": "workspaceWrite", + "writableRoots": ["/Users/me/project"], + "networkAccess": true + }, "model": "gpt-5-codex", "effort": "medium", - "summary": "focus-on-test-failures" + "summary": "concise" } } { "id": 30, "result": { "turn": { "id": "turn_456", @@ -159,7 +165,7 @@ The server requests cancellations for running subprocesses, then emits a `turn/c ## Auth endpoints -The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. +The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. ### Quick reference - `account/read` — fetch current account info; optionally refresh tokens. @@ -249,5 +255,6 @@ Field notes: ### Dev notes -- `codex generate-ts --out ` emits v2 types under `v2/`. +- `codex app-server generate-ts --out ` emits v2 types under `v2/`. +- `codex app-server generate-json-schema --out ` outputs `codex_app_server_protocol.schemas.json`. - See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 633f2605e7..1729709167 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,3 +1,4 @@ +use clap::Args; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -82,8 +83,8 @@ enum Subcommand { /// [experimental] Run the Codex MCP server (stdio transport). McpServer, - /// [experimental] Run the app server. - AppServer, + /// [experimental] Run the app server or related tooling. + AppServer(AppServerCommand), /// Generate shell completion scripts. Completion(CompletionCommand), @@ -99,9 +100,6 @@ enum Subcommand { /// Resume a previous interactive session (picker by default; use --last to continue the most recent). Resume(ResumeCommand), - /// [experimental] Generate TypeScript bindings for the app server protocol. - GenerateTs(GenerateTsCommand), - /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -208,6 +206,22 @@ struct LogoutCommand { } #[derive(Debug, Parser)] +struct AppServerCommand { + /// Omit to run the app server; specify a subcommand for tooling. + #[command(subcommand)] + subcommand: Option, +} + +#[derive(Debug, clap::Subcommand)] +enum AppServerSubcommand { + /// [experimental] Generate TypeScript bindings for the app server protocol. + GenerateTs(GenerateTsCommand), + + /// [experimental] Generate JSON Schema for the app server protocol. + GenerateJsonSchema(GenerateJsonSchemaCommand), +} + +#[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written #[arg(short = 'o', long = "out", value_name = "DIR")] @@ -218,6 +232,13 @@ struct GenerateTsCommand { prettier: Option, } +#[derive(Debug, Args)] +struct GenerateJsonSchemaCommand { + /// Output directory where the schema bundle will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, +} + #[derive(Debug, Parser)] struct StdioToUdsCommand { /// Path to the Unix domain socket to connect to. @@ -410,9 +431,20 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; } - Some(Subcommand::AppServer) => { - codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; - } + Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand { + None => { + codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; + } + Some(AppServerSubcommand::GenerateTs(gen_cli)) => { + codex_app_server_protocol::generate_ts( + &gen_cli.out_dir, + gen_cli.prettier.as_deref(), + )?; + } + Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { + codex_app_server_protocol::generate_json(&gen_cli.out_dir)?; + } + }, Some(Subcommand::Resume(ResumeCommand { session_id, last, @@ -527,9 +559,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } - Some(Subcommand::GenerateTs(gen_cli)) => { - codex_app_server_protocol::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; - } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { // Respect root-level `-c` overrides plus top-level flags like `--profile`. From 788badd221d58a659003d4e416704c6f442bf098 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 10 Nov 2025 09:05:00 -0800 Subject: [PATCH 099/122] fix: update brew auto update version check (#6238) ### Summary * Use `https://github.com/Homebrew/homebrew-cask/blob/main/Casks/c/codex.rb` to get the latest available version for brew usage. --- codex-rs/cli/src/main.rs | 2 +- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/lib.rs | 3 +- codex-rs/tui/src/update_action.rs | 101 ++++++++++++++++++ codex-rs/tui/src/update_prompt.rs | 4 +- codex-rs/tui/src/updates.rs | 168 +++++++++++++++--------------- 7 files changed, 191 insertions(+), 93 deletions(-) create mode 100644 codex-rs/tui/src/update_action.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1729709167..75a7cb8e44 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -21,7 +21,7 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; -use codex_tui::updates::UpdateAction; +use codex_tui::update_action::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 06c13ff16f..188e813785 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -13,7 +13,7 @@ use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; -use crate::updates::UpdateAction; +use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -203,7 +203,7 @@ impl App { tui, AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( latest_version, - crate::updates::get_update_action(), + crate::update_action::get_update_action(), ))), ) .await?; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 875bb9e2bb..a9abd42f61 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -16,7 +16,7 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::updates::UpdateAction; +use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9efa5eac5b..12fc1f8138 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,8 +71,9 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; +pub mod update_action; mod update_prompt; -pub mod updates; +mod updates; mod version; mod wrapping; diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs new file mode 100644 index 0000000000..b5cf56a6b4 --- /dev/null +++ b/codex-rs/tui/src/update_action.rs @@ -0,0 +1,101 @@ +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + shlex::try_join(std::iter::once(command).chain(args.iter().copied())) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + } +} + +#[cfg(not(debug_assertions))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + + detect_update_action( + cfg!(target_os = "macos"), + &exe, + managed_by_npm, + managed_by_bun, + ) +} + +#[cfg(any(not(debug_assertions), test))] +fn detect_update_action( + is_macos: bool, + current_exe: &std::path::Path, + managed_by_npm: bool, + managed_by_bun: bool, +) -> Option { + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if is_macos + && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_update_action_without_env_mutation() { + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, false), + None + ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), true, false), + Some(UpdateAction::NpmGlobalLatest) + ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, true), + Some(UpdateAction::BunGlobalLatest) + ); + assert_eq!( + detect_update_action( + true, + std::path::Path::new("/opt/homebrew/bin/codex"), + false, + false + ), + Some(UpdateAction::BrewUpgrade) + ); + assert_eq!( + detect_update_action( + true, + std::path::Path::new("/usr/local/bin/codex"), + false, + false + ), + Some(UpdateAction::BrewUpgrade) + ); + } +} diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index d505385b9e..43ee0dbd40 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -10,8 +10,8 @@ use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::update_action::UpdateAction; use crate::updates; -use crate::updates::UpdateAction; use codex_core::config::Config; use color_eyre::Result; use crossterm::event::KeyCode; @@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed( let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { return Ok(UpdatePromptOutcome::Continue); }; - let Some(update_action) = crate::updates::get_update_action() else { + let Some(update_action) = crate::update_action::get_update_action() else { return Ok(UpdatePromptOutcome::Continue); }; diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 8b92af4fc9..0c3f2044ef 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,14 +1,17 @@ +#![cfg(not(debug_assertions))] + +use crate::update_action; +use crate::update_action::UpdateAction; use chrono::DateTime; use chrono::Duration; use chrono::Utc; +use codex_core::config::Config; +use codex_core::default_client::create_client; use serde::Deserialize; use serde::Serialize; use std::path::Path; use std::path::PathBuf; -use codex_core::config::Config; -use codex_core::default_client::create_client; - use crate::version::CODEX_CLI_VERSION; pub fn get_upgrade_version(config: &Config) -> Option { @@ -47,14 +50,17 @@ struct VersionInfo { dismissed_version: Option, } +const VERSION_FILENAME: &str = "version.json"; +// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind. +const HOMEBREW_CASK_URL: &str = + "https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb"; +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; + #[derive(Deserialize, Debug, Clone)] struct ReleaseInfo { tag_name: String, } -const VERSION_FILENAME: &str = "version.json"; -const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; - fn version_filepath(config: &Config) -> PathBuf { config.codex_home.join(VERSION_FILENAME) } @@ -65,23 +71,35 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { } async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { - let ReleaseInfo { - tag_name: latest_tag_name, - } = create_client() - .get(LATEST_RELEASE_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; + let latest_version = match update_action::get_update_action() { + Some(UpdateAction::BrewUpgrade) => { + let cask_contents = create_client() + .get(HOMEBREW_CASK_URL) + .send() + .await? + .error_for_status()? + .text() + .await?; + extract_version_from_cask(&cask_contents)? + } + _ => { + let ReleaseInfo { + tag_name: latest_tag_name, + } = create_client() + .get(LATEST_RELEASE_URL) + .send() + .await? + .error_for_status()? + .json::() + .await?; + extract_version_from_latest_tag(&latest_tag_name)? + } + }; // Preserve any previously dismissed version if present. let prev_info = read_version_info(version_file).ok(); let info = VersionInfo { - latest_version: latest_tag_name - .strip_prefix("rust-v") - .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? - .into(), + latest_version, last_checked_at: Utc::now(), dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; @@ -101,6 +119,25 @@ fn is_newer(latest: &str, current: &str) -> Option { } } +fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result { + cask_contents + .lines() + .find_map(|line| { + let line = line.trim(); + line.strip_prefix("version \"") + .and_then(|rest| rest.strip_suffix('"')) + .map(ToString::to_string) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file")) +} + +fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { + latest_tag_name + .strip_prefix("rust-v") + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")) +} + /// Returns the latest version to show in a popup, if it should be shown. /// This respects the user's dismissal choice for the current latest version. pub fn get_upgrade_version_for_popup(config: &Config) -> Option { @@ -140,57 +177,36 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { Some((maj, min, pat)) } -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via `brew upgrade codex`. - BrewUpgrade, -} - -#[cfg(any(not(debug_assertions), test))] -pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None - } -} - -impl UpdateAction { - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (&'static str, &'static [&'static str]) { - match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), - } - } - - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(self) -> String { - let (command, args) = self.command_args(); - let args_str = args.join(" "); - format!("{command} {args_str}") - } -} - #[cfg(test)] mod tests { use super::*; + #[test] + fn parses_version_from_cask_contents() { + let cask = r#" + cask "codex" do + version "0.55.0" + end + "#; + assert_eq!( + extract_version_from_cask(cask).expect("failed to parse version"), + "0.55.0" + ); + } + + #[test] + fn extracts_version_from_latest_tag() { + assert_eq!( + extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"), + "1.5.0" + ); + } + + #[test] + fn latest_tag_without_prefix_is_invalid() { + assert!(extract_version_from_latest_tag("v1.5.0").is_err()); + } + #[test] fn prerelease_version_is_not_considered_newer() { assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); @@ -210,24 +226,4 @@ mod tests { assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); } - - #[test] - fn test_get_update_action() { - let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); - - // First: no npm var -> expect None (we do not run from brew in CI) - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - assert_eq!(get_update_action(), None); - - // Then: with npm var -> expect NpmGlobalLatest - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; - assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); - - // Restore prior value to avoid leaking state - if let Some(v) = prev { - unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; - } else { - unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; - } - } } From e743d251a7c7c63523e7d759daf79c62b39d14d7 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 10 Nov 2025 09:21:53 -0800 Subject: [PATCH 100/122] Add opt-out for rate limit model nudge (#6433) ## Summary - add a `hide_rate_limit_model_nudge` notice flag plus config edit plumbing so the rate limit reminder preference is persisted and documented - extend the chat widget prompt with a "never show again" option, and wire new app events so selecting it hides future nudges immediately and writes the config - add unit coverage and refresh the snapshot for the three-option prompt ## Testing - `just fmt` - `just fix -p codex-tui` - `just fix -p codex-core` - `cargo test -p codex-tui` - `cargo test -p codex-core` *(fails at `exec::tests::kill_child_process_group_kills_grandchildren_on_timeout`: grandchild process still alive)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_6910d7f407748321b2661fc355416994) --- codex-rs/core/src/config/edit.rs | 41 +++++++++++++++++++ codex-rs/core/src/config/types.rs | 2 + codex-rs/tui/src/app.rs | 18 ++++++++ codex-rs/tui/src/app_event.rs | 6 +++ codex-rs/tui/src/chatwidget.rs | 34 +++++++++++++++ ...tests__rate_limit_switch_prompt_popup.snap | 7 +++- codex-rs/tui/src/chatwidget/tests.rs | 16 ++++++++ docs/example-config.md | 1 + 8 files changed, 123 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 64974c2445..df0b127335 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -25,6 +25,8 @@ pub enum ConfigEdit { SetNoticeHideFullAccessWarning(bool), /// Toggle the Windows world-writable directories warning acknowledgement flag. SetNoticeHideWorldWritableWarning(bool), + /// Toggle the rate limit model nudge acknowledgement flag. + SetNoticeHideRateLimitModelNudge(bool), /// Toggle the Windows onboarding acknowledgement flag. SetWindowsWslSetupAcknowledged(bool), /// Replace the entire `[mcp_servers]` table. @@ -246,6 +248,11 @@ impl ConfigDocument { &[Notice::TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), + ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( + Scope::Global, + &[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"], + value(*acknowledged), + )), ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( Scope::Global, &["windows_wsl_setup_acknowledged"], @@ -486,6 +493,12 @@ impl ConfigEditsBuilder { self } + pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); + self + } + pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); @@ -733,6 +746,34 @@ hide_full_access_warning = true assert_eq!(contents, expected); } + #[test] + fn blocking_set_hide_rate_limit_model_nudge_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], + ) + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" +hide_rate_limit_model_nudge = true +"#; + assert_eq!(contents, expected); + } + #[test] fn blocking_replace_mcp_servers_round_trips() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index a5fd71d173..c1dae19f0d 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -360,6 +360,8 @@ pub struct Notice { pub hide_full_access_warning: Option, /// Tracks whether the user has acknowledged the Windows world-writable directories warning. pub hide_world_writable_warning: Option, + /// Tracks whether the user opted out of the rate limit model switch reminder. + pub hide_rate_limit_model_nudge: Option, } impl Notice { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 188e813785..ed5a2415b8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -499,6 +499,9 @@ impl App { self.chat_widget .set_world_writable_warning_acknowledged(ack); } + AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { + self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); + } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(true) @@ -529,6 +532,21 @@ impl App { )); } } + AppEvent::PersistRateLimitSwitchPromptHidden => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_rate_limit_model_nudge(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist rate limit switch prompt preference" + ); + self.chat_widget.add_error_message(format!( + "Failed to save rate limit reminder preference: {err}" + )); + } + } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7068cb79d4..54850d69b5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -104,6 +104,9 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] UpdateWorldWritableWarningAcknowledged(bool), + /// Update whether the rate limit switch prompt has been acknowledged for the session. + UpdateRateLimitSwitchPromptHidden(bool), + /// Persist the acknowledgement flag for the full access warning prompt. PersistFullAccessWarningAcknowledged, @@ -111,6 +114,9 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] PersistWorldWritableWarningAcknowledged, + /// Persist the acknowledgement flag for the rate limit switch prompt. + PersistRateLimitSwitchPromptHidden, + /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] SkipNextWorldWritableScan, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2a5eb276f4..0665709d54 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -522,6 +522,7 @@ impl ChatWidget { .unwrap_or(false); if high_usage + && !self.rate_limit_switch_prompt_hidden() && self.config.model != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, @@ -1710,7 +1711,18 @@ impl ChatWidget { .find(|preset| preset.model == NUDGE_MODEL_SLUG) } + fn rate_limit_switch_prompt_hidden(&self) -> bool { + self.config + .notices + .hide_rate_limit_model_nudge + .unwrap_or(false) + } + fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.rate_limit_switch_prompt_hidden() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + return; + } if !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Pending @@ -1744,6 +1756,10 @@ impl ChatWidget { })]; let keep_actions: Vec = Vec::new(); + let never_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); + tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); + })]; let description = if preset.description.is_empty() { Some("Uses fewer credits for upcoming turns.".to_string()) } else { @@ -1769,6 +1785,17 @@ impl ChatWidget { dismiss_on_select: true, ..Default::default() }, + SelectionItem { + name: "Keep current model (never show again)".to_string(), + description: Some( + "Hide future rate limit reminders about switching models.".to_string(), + ), + selected_description: None, + is_current: false, + actions: never_actions, + dismiss_on_select: true, + ..Default::default() + }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -2386,6 +2413,13 @@ impl ChatWidget { self.config.notices.hide_world_writable_warning = Some(acknowledged); } + pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { + self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + if hidden { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn world_writable_warning_hidden(&self) -> bool { self.config diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index f21400e1bd..c89d65a503 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,12 +1,15 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 500 expression: popup --- Approaching rate limits Switch to gpt-5-codex-mini for lower credit usage? -› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, but - less capable. +› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, + faster, but less capable. 2. Keep current model + 3. Keep current model (never show again) Hide future rate limit reminders + about switching models. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5bb89a329a..f3c13c0fb0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -448,6 +448,22 @@ fn rate_limit_switch_prompt_shows_once_per_session() { )); } +#[test] +fn rate_limit_switch_prompt_respects_hidden_notice() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let (mut chat, _, _) = make_chatwidget_manual(); + chat.config.model = "gpt-5".to_string(); + chat.auth_manager = AuthManager::from_auth_for_testing(auth); + chat.config.notices.hide_rate_limit_model_nudge = Some(true); + + chat.on_rate_limit_snapshot(Some(snapshot(95.0))); + + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); +} + #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); diff --git a/docs/example-config.md b/docs/example-config.md index 524cbbe02c..573e3ed9b0 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -159,6 +159,7 @@ windows_wsl_setup_acknowledged = false # In-product notices (mostly set automatically by Codex). [notice] # hide_full_access_warning = true +# hide_rate_limit_model_nudge = true ################################################################################ # Authentication & Login From 980886498ccb9c6879842111d39b57943de210db Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 10 Nov 2025 11:18:45 -0800 Subject: [PATCH 101/122] Add user command event types (#6246) adding new user command event, logic in TUI to render user command events --- codex-rs/core/src/event_mapping.rs | 12 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/tasks/user_shell.rs | 157 +++++++++++++---- codex-rs/core/src/user_shell_command.rs | 108 ++++++++++++ codex-rs/core/tests/common/responses.rs | 12 ++ codex-rs/core/tests/suite/user_shell_cmd.rs | 179 ++++++++++++++++---- codex-rs/tui/src/exec_cell/render.rs | 8 +- 7 files changed, 411 insertions(+), 66 deletions(-) create mode 100644 codex-rs/core/src/user_shell_command.rs diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index c9edd5409e..d0aa1d8188 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -14,6 +14,7 @@ use tracing::warn; use uuid::Uuid; use crate::user_instructions::UserInstructions; +use crate::user_shell_command::is_user_shell_command_text; fn is_session_prefix(text: &str) -> bool { let trimmed = text.trim_start(); @@ -31,7 +32,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { for content_item in message.iter() { match content_item { ContentItem::InputText { text } => { - if is_session_prefix(text) { + if is_session_prefix(text) || is_user_shell_command_text(text) { return None; } content.push(UserInput::Text { text: text.clone() }); @@ -197,7 +198,14 @@ mod tests { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], }, - ]; + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "echo 42".to_string(), + }], + }, + ]; for item in items { let turn_item = parse_turn_item(&item); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0738e7afa9..5229d00606 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -81,6 +81,7 @@ mod function_tool; mod state; mod tasks; mod user_notification; +mod user_shell_command; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 0e57e1b728..28a84f23f3 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -1,28 +1,35 @@ use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; -use codex_protocol::models::ShellToolCallParams; +use codex_async_utils::CancelErr; +use codex_async_utils::OrCancelExt; use codex_protocol::user_input::UserInput; -use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::SandboxType; +use crate::exec::StdoutStream; +use crate::exec::StreamOutput; +use crate::exec::execute_exec_env; +use crate::exec_env::create_env; +use crate::parse_command::parse_command; use crate::protocol::EventMsg; +use crate::protocol::ExecCommandBeginEvent; +use crate::protocol::ExecCommandEndEvent; +use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; +use crate::sandboxing::ExecEnv; use crate::state::TaskKind; -use crate::tools::context::ToolPayload; -use crate::tools::parallel::ToolCallRuntime; -use crate::tools::router::ToolCall; -use crate::tools::router::ToolRouter; -use crate::turn_diff_tracker::TurnDiffTracker; +use crate::tools::format_exec_output_str; +use crate::user_shell_command::user_shell_command_record_item; use super::SessionTask; use super::SessionTaskContext; -const USER_SHELL_TOOL_NAME: &str = "local_shell"; - #[derive(Clone)] pub(crate) struct UserShellCommandTask { command: String, @@ -78,34 +85,126 @@ impl SessionTask for UserShellCommandTask { } }; - let params = ShellToolCallParams { + let call_id = Uuid::new_v4().to_string(); + let raw_command = self.command.clone(); + + let parsed_cmd = parse_command(&shell_invocation); + session + .send_event( + turn_context.as_ref(), + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: call_id.clone(), + command: shell_invocation.clone(), + cwd: turn_context.cwd.clone(), + parsed_cmd, + is_user_shell_command: true, + }), + ) + .await; + + let exec_env = ExecEnv { command: shell_invocation, - workdir: None, + cwd: turn_context.cwd.clone(), + env: create_env(&turn_context.shell_environment_policy), timeout_ms: None, + sandbox: SandboxType::None, with_escalated_permissions: None, justification: None, + arg0: None, }; - let tool_call = ToolCall { - tool_name: USER_SHELL_TOOL_NAME.to_string(), - call_id: Uuid::new_v4().to_string(), - payload: ToolPayload::LocalShell { params }, - }; + let stdout_stream = Some(StdoutStream { + sub_id: turn_context.sub_id.clone(), + call_id: call_id.clone(), + tx_event: session.get_tx_event(), + }); - let router = Arc::new(ToolRouter::from_config(&turn_context.tools_config, None)); - let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); - let runtime = ToolCallRuntime::new( - Arc::clone(&router), - Arc::clone(&session), - Arc::clone(&turn_context), - Arc::clone(&tracker), - ); + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream) + .or_cancel(&cancellation_token) + .await; - if let Err(err) = runtime - .handle_tool_call(tool_call, cancellation_token) - .await - { - error!("user shell command failed: {err:?}"); + match exec_result { + Err(CancelErr::Cancelled) => { + let aborted_message = "command aborted by user".to_string(); + let exec_output = ExecToolCallOutput { + exit_code: -1, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(aborted_message.clone()), + aggregated_output: StreamOutput::new(aborted_message.clone()), + duration: Duration::ZERO, + timed_out: false, + }; + let output_items = [user_shell_command_record_item(&raw_command, &exec_output)]; + session + .record_conversation_items(turn_context.as_ref(), &output_items) + .await; + session + .send_event( + turn_context.as_ref(), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + stdout: String::new(), + stderr: aborted_message.clone(), + aggregated_output: aborted_message.clone(), + exit_code: -1, + duration: Duration::ZERO, + formatted_output: aborted_message, + }), + ) + .await; + } + Ok(Ok(output)) => { + session + .send_event( + turn_context.as_ref(), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: call_id.clone(), + stdout: output.stdout.text.clone(), + stderr: output.stderr.text.clone(), + aggregated_output: output.aggregated_output.text.clone(), + exit_code: output.exit_code, + duration: output.duration, + formatted_output: format_exec_output_str(&output), + }), + ) + .await; + + let output_items = [user_shell_command_record_item(&raw_command, &output)]; + session + .record_conversation_items(turn_context.as_ref(), &output_items) + .await; + } + Ok(Err(err)) => { + error!("user shell command failed: {err:?}"); + let message = format!("execution error: {err:?}"); + let exec_output = ExecToolCallOutput { + exit_code: -1, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(message.clone()), + aggregated_output: StreamOutput::new(message.clone()), + duration: Duration::ZERO, + timed_out: false, + }; + session + .send_event( + turn_context.as_ref(), + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + stdout: exec_output.stdout.text.clone(), + stderr: exec_output.stderr.text.clone(), + aggregated_output: exec_output.aggregated_output.text.clone(), + exit_code: exec_output.exit_code, + duration: exec_output.duration, + formatted_output: format_exec_output_str(&exec_output), + }), + ) + .await; + let output_items = [user_shell_command_record_item(&raw_command, &exec_output)]; + session + .record_conversation_items(turn_context.as_ref(), &output_items) + .await; + } } None } diff --git a/codex-rs/core/src/user_shell_command.rs b/codex-rs/core/src/user_shell_command.rs new file mode 100644 index 0000000000..7f0731c962 --- /dev/null +++ b/codex-rs/core/src/user_shell_command.rs @@ -0,0 +1,108 @@ +use std::time::Duration; + +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; + +use crate::exec::ExecToolCallOutput; +use crate::tools::format_exec_output_str; + +pub const USER_SHELL_COMMAND_OPEN: &str = ""; +pub const USER_SHELL_COMMAND_CLOSE: &str = ""; + +pub fn is_user_shell_command_text(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(USER_SHELL_COMMAND_OPEN) +} + +fn format_duration_line(duration: Duration) -> String { + let duration_seconds = duration.as_secs_f64(); + format!("Duration: {duration_seconds:.4} seconds") +} + +fn format_user_shell_command_body(command: &str, exec_output: &ExecToolCallOutput) -> String { + let mut sections = Vec::new(); + sections.push("".to_string()); + sections.push(command.to_string()); + sections.push("".to_string()); + sections.push("".to_string()); + sections.push(format!("Exit code: {}", exec_output.exit_code)); + sections.push(format_duration_line(exec_output.duration)); + sections.push("Output:".to_string()); + sections.push(format_exec_output_str(exec_output)); + sections.push("".to_string()); + sections.join("\n") +} + +pub fn format_user_shell_command_record(command: &str, exec_output: &ExecToolCallOutput) -> String { + let body = format_user_shell_command_body(command, exec_output); + format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}") +} + +pub fn user_shell_command_record_item( + command: &str, + exec_output: &ExecToolCallOutput, +) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format_user_shell_command_record(command, exec_output), + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::exec::StreamOutput; + use pretty_assertions::assert_eq; + + #[test] + fn detects_user_shell_command_text_variants() { + assert!(is_user_shell_command_text( + "\necho hi\n" + )); + assert!(!is_user_shell_command_text("echo hi")); + } + + #[test] + fn formats_basic_record() { + let exec_output = ExecToolCallOutput { + exit_code: 0, + stdout: StreamOutput::new("hi".to_string()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new("hi".to_string()), + duration: Duration::from_secs(1), + timed_out: false, + }; + let item = user_shell_command_record_item("echo hi", &exec_output); + let ResponseItem::Message { content, .. } = item else { + panic!("expected message"); + }; + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected input text"); + }; + assert_eq!( + text, + "\n\necho hi\n\n\nExit code: 0\nDuration: 1.0000 seconds\nOutput:\nhi\n\n" + ); + } + + #[test] + fn uses_aggregated_output_over_streams() { + let exec_output = ExecToolCallOutput { + exit_code: 42, + stdout: StreamOutput::new("stdout-only".to_string()), + stderr: StreamOutput::new("stderr-only".to_string()), + aggregated_output: StreamOutput::new("combined output wins".to_string()), + duration: Duration::from_millis(120), + timed_out: false, + }; + let record = format_user_shell_command_record("false", &exec_output); + assert_eq!( + record, + "\n\nfalse\n\n\nExit code: 42\nDuration: 0.1200 seconds\nOutput:\ncombined output wins\n\n" + ); + } +} diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 4fd6dcf781..1eaf9d0c99 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -61,6 +61,18 @@ impl ResponsesRequest { self.0.body_json().unwrap() } + /// Returns all `input_text` spans from `message` inputs for the provided role. + pub fn message_input_texts(&self, role: &str) -> Vec { + self.inputs_of_type("message") + .into_iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array).cloned()) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() + } + pub fn input(&self) -> Vec { self.0.body_json::().unwrap()["input"] .as_array() diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 0832d7e6b4..fa09f0056d 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -2,35 +2,20 @@ use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::ExecOutputStream; use codex_core::protocol::Op; use codex_core::protocol::TurnAbortReason; +use core_test_support::assert_regex_match; use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use regex_lite::escape; use std::path::PathBuf; -use std::process::Command; -use std::process::Stdio; use tempfile::TempDir; -fn detect_python_executable() -> Option { - let candidates = ["python3", "python"]; - candidates.iter().find_map(|candidate| { - Command::new(candidate) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .ok() - .and_then(|status| status.success().then(|| (*candidate).to_string())) - }) -} - #[tokio::test] async fn user_shell_cmd_ls_and_cat_in_temp_dir() { - let Some(python) = detect_python_executable() else { - eprintln!("skipping test: python3 not found in PATH"); - return; - }; - // Create a temporary working directory with a known file. let cwd = TempDir::new().unwrap(); let file_name = "hello.txt"; @@ -55,10 +40,8 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { .await .expect("create new conversation"); - // 1) python should list the file - let list_cmd = format!( - "{python} -c \"import pathlib; print('\\n'.join(sorted(p.name for p in pathlib.Path('.').iterdir())))\"" - ); + // 1) shell command should list the file + let list_cmd = "ls".to_string(); codex .submit(Op::RunUserShellCommand { command: list_cmd }) .await @@ -76,10 +59,8 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { "ls output should include {file_name}, got: {stdout:?}" ); - // 2) python should print the file contents verbatim - let cat_cmd = format!( - "{python} -c \"import pathlib; print(pathlib.Path('{file_name}').read_text(), end='')\"" - ); + // 2) shell command should print the file contents verbatim + let cat_cmd = format!("cat {file_name}"); codex .submit(Op::RunUserShellCommand { command: cat_cmd }) .await @@ -95,7 +76,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { }; assert_eq!(exit_code, 0); if cfg!(windows) { - // Windows' Python writes CRLF line endings; normalize so the assertion remains portable. + // Windows shells emit CRLF line endings; normalize so the assertion remains portable. stdout = stdout.replace("\r\n", "\n"); } assert_eq!(stdout, contents); @@ -103,10 +84,6 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { #[tokio::test] async fn user_shell_cmd_can_be_interrupted() { - let Some(python) = detect_python_executable() else { - eprintln!("skipping test: python3 not found in PATH"); - return; - }; // Set up isolated config and conversation. let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); @@ -121,7 +98,7 @@ async fn user_shell_cmd_can_be_interrupted() { .expect("create new conversation"); // Start a long-running command and then interrupt it. - let sleep_cmd = format!("{python} -c \"import time; time.sleep(5)\""); + let sleep_cmd = "sleep 5".to_string(); codex .submit(Op::RunUserShellCommand { command: sleep_cmd }) .await @@ -138,3 +115,137 @@ async fn user_shell_cmd_can_be_interrupted() { }; assert_eq!(ev.reason, TurnAbortReason::Interrupted); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let mut builder = core_test_support::test_codex::test_codex(); + let test = builder.build(&server).await?; + + #[cfg(windows)] + let command = r#"$val = $env:CODEX_SANDBOX; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); + #[cfg(not(windows))] + let command = r#"sh -c "printf '%s' \"${CODEX_SANDBOX:-not-set}\"""#.to_string(); + + test.codex + .submit(Op::RunUserShellCommand { + command: command.clone(), + }) + .await?; + + let begin_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::ExecCommandBegin(event) => Some(event.clone()), + _ => None, + }) + .await; + assert!(begin_event.is_user_shell_command); + let matches_last_arg = begin_event.command.last() == Some(&command); + let matches_split = shlex::split(&command).is_some_and(|split| split == begin_event.command); + assert!( + matches_last_arg || matches_split, + "user command begin event should include the original command; got: {:?}", + begin_event.command + ); + + let delta_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::ExecCommandOutputDelta(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(delta_event.stream, ExecOutputStream::Stdout); + let chunk_text = + String::from_utf8(delta_event.chunk.clone()).expect("user command chunk is valid utf-8"); + assert_eq!(chunk_text.trim(), "not-set"); + + let end_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::ExecCommandEnd(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(end_event.exit_code, 0); + assert_eq!(end_event.stdout.trim(), "not-set"); + + let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ])]; + let mock = responses::mount_sse_sequence(&server, responses).await; + + test.submit_turn("follow-up after shell command").await?; + + let request = mock.single_request(); + + let command_message = request + .message_input_texts("user") + .into_iter() + .find(|text| text.contains("")) + .expect("command message recorded in request"); + let command_message = command_message.replace("\r\n", "\n"); + let escaped_command = escape(&command); + let expected_pattern = format!( + r"(?m)\A\n\n{escaped_command}\n\n\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nnot-set\n\n\z" + ); + assert_regex_match(&expected_pattern, &command_message); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> { + let server = responses::start_mock_server().await; + let mut builder = core_test_support::test_codex::test_codex(); + let test = builder.build(&server).await?; + + #[cfg(windows)] + let command = r#"for ($i=1; $i -le 400; $i++) { Write-Output $i }"#.to_string(); + #[cfg(not(windows))] + let command = "seq 1 400".to_string(); + + test.codex + .submit(Op::RunUserShellCommand { + command: command.clone(), + }) + .await?; + + let end_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::ExecCommandEnd(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(end_event.exit_code, 0); + + let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ])]; + let mock = responses::mount_sse_sequence(&server, responses).await; + + test.submit_turn("follow-up after shell command").await?; + + let request = mock.single_request(); + let command_message = request + .message_input_texts("user") + .into_iter() + .find(|text| text.contains("")) + .expect("command message recorded in request"); + let command_message = command_message.replace("\r\n", "\n"); + + let head = (1..=128).map(|i| format!("{i}\n")).collect::(); + let tail = (273..=400).map(|i| format!("{i}\n")).collect::(); + let truncated_body = + format!("Total output lines: 400\n\n{head}\n[... omitted 144 of 400 lines ...]\n\n{tail}"); + let escaped_command = escape(&command); + let escaped_truncated_body = escape(&truncated_body); + let expected_pattern = format!( + r"(?m)\A\n\n{escaped_command}\n\n\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{escaped_truncated_body}\n\n\z" + ); + assert_regex_match(&expected_pattern, &command_message); + + Ok(()) +} diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3ccc0527e9..8ebc125125 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -317,7 +317,13 @@ impl ExecCell { Some(false) => "•".red().bold(), None => spinner(call.start_time), }; - let title = if self.is_active() { "Running" } else { "Ran" }; + let title = if self.is_active() { + "Running" + } else if call.is_user_shell_command { + "You ran" + } else { + "Ran" + }; let mut header_line = Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]); From f01f2ec9ee4ca92870afaebfb137bdfd88d89ace Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 10 Nov 2025 19:53:36 +0000 Subject: [PATCH 102/122] feat: add workdir to unified_exec (#6466) --- .../core/src/tools/handlers/unified_exec.rs | 14 +++- codex-rs/core/src/tools/spec.rs | 9 ++ codex-rs/core/src/unified_exec/mod.rs | 2 + .../core/src/unified_exec/session_manager.rs | 21 +++-- codex-rs/core/tests/suite/unified_exec.rs | 84 +++++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 593925a726..6542c333cc 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use async_trait::async_trait; use serde::Deserialize; @@ -24,6 +26,8 @@ pub struct UnifiedExecHandler; #[derive(Debug, Deserialize)] struct ExecCommandArgs { cmd: String, + #[serde(default)] + workdir: Option, #[serde(default = "default_shell")] shell: String, #[serde(default = "default_login")] @@ -96,6 +100,12 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse exec_command arguments: {err:?}" )) })?; + let workdir = args + .workdir + .as_deref() + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); let event_ctx = ToolEventCtx::new( context.session.as_ref(), @@ -103,8 +113,7 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); - let emitter = - ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true); + let emitter = ToolEmitter::unified_exec(args.cmd.clone(), cwd.clone(), true); emitter.emit(event_ctx, ToolEventStage::Begin).await; manager @@ -115,6 +124,7 @@ impl ToolHandler for UnifiedExecHandler { login: args.login, yield_time_ms: args.yield_time_ms, max_output_tokens: args.max_output_tokens, + workdir, }, &context, ) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 1e4a0d9e68..cb1aeafdb2 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -138,6 +138,15 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell command to execute.".to_string()), }, ); + properties.insert( + "workdir".to_string(), + JsonSchema::String { + description: Some( + "Optional working directory to run the command in; defaults to the turn cwd." + .to_string(), + ), + }, + ); properties.insert( "shell".to_string(), JsonSchema::String { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index a1e2c0c444..16fbc4c7f0 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -70,6 +70,7 @@ pub(crate) struct ExecCommandRequest<'a> { pub login: bool, pub yield_time_ms: Option, pub max_output_tokens: Option, + pub workdir: Option, } #[derive(Debug)] @@ -199,6 +200,7 @@ mod tests { login: true, yield_time_ms, max_output_tokens: None, + workdir: None, }, &context, ) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 7f4727d0c9..bc10fc2bd8 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -38,6 +39,10 @@ impl UnifiedExecSessionManager { request: ExecCommandRequest<'_>, context: &UnifiedExecContext, ) -> Result { + let cwd = request + .workdir + .clone() + .unwrap_or_else(|| context.turn.cwd.clone()); let shell_flag = if request.login { "-lc" } else { "-c" }; let command = vec![ request.shell.to_string(), @@ -45,7 +50,9 @@ impl UnifiedExecSessionManager { request.command.to_string(), ]; - let session = self.open_session_with_sandbox(command, context).await?; + let session = self + .open_session_with_sandbox(command, cwd.clone(), context) + .await?; let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = @@ -66,7 +73,7 @@ impl UnifiedExecSessionManager { None } else { Some( - self.store_session(session, context, request.command, start) + self.store_session(session, context, request.command, cwd.clone(), start) .await, ) }; @@ -87,6 +94,7 @@ impl UnifiedExecSessionManager { Self::emit_exec_end_from_context( context, request.command.to_string(), + cwd, response.output.clone(), exit, response.wall_time, @@ -211,6 +219,7 @@ impl UnifiedExecSessionManager { session: UnifiedExecSession, context: &UnifiedExecContext, command: &str, + cwd: PathBuf, started_at: Instant, ) -> i32 { let session_id = self @@ -222,7 +231,7 @@ impl UnifiedExecSessionManager { turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), command: command.to_string(), - cwd: context.turn.cwd.clone(), + cwd, started_at, }; self.sessions.lock().await.insert(session_id, entry); @@ -258,6 +267,7 @@ impl UnifiedExecSessionManager { async fn emit_exec_end_from_context( context: &UnifiedExecContext, command: String, + cwd: PathBuf, aggregated_output: String, exit_code: i32, duration: Duration, @@ -276,7 +286,7 @@ impl UnifiedExecSessionManager { &context.call_id, None, ); - let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true); + let emitter = ToolEmitter::unified_exec(command, cwd, true); emitter .emit(event_ctx, ToolEventStage::Success(output)) .await; @@ -300,13 +310,14 @@ impl UnifiedExecSessionManager { pub(super) async fn open_session_with_sandbox( &self, command: Vec, + cwd: PathBuf, context: &UnifiedExecContext, ) -> Result { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let req = UnifiedExecToolRequest::new( command, - context.turn.cwd.clone(), + cwd, create_env(&context.turn.shell_environment_policy), ); let tool_ctx = ToolCtx { diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 50918c1475..cd8b338200 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -223,6 +223,90 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_respects_workdir_override() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let workdir = cwd.path().join("uexec_workdir_test"); + std::fs::create_dir_all(&workdir)?; + + let call_id = "uexec-workdir"; + let args = json!({ + "cmd": "pwd", + "yield_time_ms": 250, + "workdir": workdir.to_string_lossy().to_string(), + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run workdir test".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs + .get(call_id) + .expect("missing exec_command workdir output"); + let output_text = output.output.trim(); + let output_canonical = std::fs::canonicalize(output_text)?; + let expected_canonical = std::fs::canonicalize(&workdir)?; + assert_eq!( + output_canonical, expected_canonical, + "pwd should reflect the requested workdir override" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_emits_exec_command_end_event() -> Result<()> { skip_if_no_network!(Ok(())); From 2ac49fea58b1a54b136236ff6963c3518a11f5f8 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 10 Nov 2025 12:24:36 -0800 Subject: [PATCH 103/122] [app-server] chore: move initialize out of deprecated API section (#6468) Self-explanatory - `initialize` is not a deprecated API and works equally well with the v2 APIs. --- codex-rs/app-server-protocol/src/protocol/common.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2c52067bbd..b4cde11000 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -101,6 +101,11 @@ macro_rules! client_request_definitions { } client_request_definitions! { + Initialize { + params: v1::InitializeParams, + response: v1::InitializeResponse, + }, + /// NEW APIs // Thread lifecycle #[serde(rename = "thread/start")] @@ -196,10 +201,6 @@ client_request_definitions! { }, /// DEPRECATED APIs below - Initialize { - params: v1::InitializeParams, - response: v1::InitializeResponse, - }, NewConversation { params: v1::NewConversationParams, response: v1::NewConversationResponse, From 52e97b9b6b0428323d3222361763971d9c4a32c9 Mon Sep 17 00:00:00 2001 From: George Nesterenok <89010995+MasterMind7777777@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:35:30 -0300 Subject: [PATCH 104/122] Fix wayland image paste error (#4824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - log and surface clipboard failures instead of silently ignoring them when `Ctrl+V` pastes an image (`paste_image_to_temp_png()` now feeds an error history cell) - enable `arboard`’s `wayland-data-control` feature so native Wayland sessions can deliver image data without XWayland - keep the success path unchanged: valid images still attach and show the `[image …]` placeholder as before Fixes #4818 --------- Co-authored-by: Eric Traut Co-authored-by: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> --- codex-rs/Cargo.lock | 121 +++++++++++++++++++++++++++++++++ codex-rs/Cargo.toml | 2 +- codex-rs/tui/src/chatwidget.rs | 17 ++++- 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ec6420a05f..8bc54ff045 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -211,6 +211,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.59.0", + "wl-clipboard-rs", "x11rb", ] @@ -4287,6 +4288,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "os_pipe" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "owo-colors" version = "4.2.2" @@ -4663,6 +4674,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.0" @@ -6700,6 +6720,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" +[[package]] +name = "tree_magic_mini" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" +dependencies = [ + "memchr", + "nom", + "once_cell", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7037,6 +7069,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.0.8", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +dependencies = [ + "bitflags 2.9.1", + "rustix 1.0.8", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -7608,6 +7710,25 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 0.38.44", + "tempfile", + "thiserror 2.0.16", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index dd074e1de0..12b0fe9cd9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -94,7 +94,7 @@ mcp_test_support = { path = "mcp-server/tests/common" } allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" -arboard = "3" +arboard = { version = "3", features = ["wayland-data-control"] } askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0665709d54..7055822274 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1143,8 +1143,21 @@ impl ChatWidget { kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => { - if let Ok((path, info)) = paste_image_to_temp_png() { - self.attach_image(path, info.width, info.height, info.encoded_format.label()); + match paste_image_to_temp_png() { + Ok((path, info)) => { + self.attach_image( + path, + info.width, + info.height, + info.encoded_format.label(), + ); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}", + ))); + } } return; } From 0271c20d8f31f42545868076e1d10f4497f18b35 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:48:14 -0800 Subject: [PATCH 105/122] add codex debug seatbelt --log-denials (#4098) This adds a debugging tool for analyzing why certain commands fail to execute under the sandbox. Example output: ``` $ codex debug seatbelt --log-denials bash -lc "(echo foo > ~/foo.txt)" bash: /Users/nornagon/foo.txt: Operation not permitted === Sandbox denials === (bash) file-write-data /dev/tty (bash) file-write-data /dev/ttys001 (bash) sysctl-read kern.ngroups (bash) file-write-create /Users/nornagon/foo.txt ``` It operates by: 1. spawning `log stream` to watch system logs, and 2. tracking all descendant PIDs using kqueue + proc_listchildpids. this is a "best-effort" technique, as `log stream` may drop logs(?), and kqueue + proc_listchildpids isn't atomic and can end up missing very short-lived processes. But it works well enough in my testing to be useful :) --- codex-rs/Cargo.lock | 3 + codex-rs/README.md | 4 +- codex-rs/cli/Cargo.toml | 3 + codex-rs/cli/src/debug_sandbox.rs | 37 ++ codex-rs/cli/src/debug_sandbox/pid_tracker.rs | 372 ++++++++++++++++++ codex-rs/cli/src/debug_sandbox/seatbelt.rs | 114 ++++++ codex-rs/cli/src/lib.rs | 4 + 7 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 codex-rs/cli/src/debug_sandbox/pid_tracker.rs create mode 100644 codex-rs/cli/src/debug_sandbox/seatbelt.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8bc54ff045..2ed86153f8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -986,14 +986,17 @@ dependencies = [ "codex-tui", "codex-windows-sandbox", "ctor 0.5.0", + "libc", "owo-colors", "predicates", "pretty_assertions", + "regex-lite", "serde_json", "supports-color", "tempfile", "tokio", "toml", + "tracing", ] [[package]] diff --git a/codex-rs/README.md b/codex-rs/README.md index 36143558d3..385b4c62e5 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -58,7 +58,7 @@ To test to see what happens when a command is run under the sandbox provided by ``` # macOS -codex sandbox macos [--full-auto] [COMMAND]... +codex sandbox macos [--full-auto] [--log-denials] [COMMAND]... # Linux codex sandbox linux [--full-auto] [COMMAND]... @@ -67,7 +67,7 @@ codex sandbox linux [--full-auto] [COMMAND]... codex sandbox windows [--full-auto] [COMMAND]... # Legacy aliases -codex debug seatbelt [--full-auto] [COMMAND]... +codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... ``` diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c5a55bb2e2..deddc068cd 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -35,7 +35,9 @@ codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } +libc = { workspace = true } owo-colors = { workspace = true } +regex-lite = { workspace = true} serde_json = { workspace = true } supports-color = { workspace = true } toml = { workspace = true } @@ -46,6 +48,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tracing = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index cbb66af815..0b325fbe6e 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -1,3 +1,8 @@ +#[cfg(target_os = "macos")] +mod pid_tracker; +#[cfg(target_os = "macos")] +mod seatbelt; + use std::path::PathBuf; use codex_common::CliConfigOverrides; @@ -15,6 +20,9 @@ use crate::SeatbeltCommand; use crate::WindowsCommand; use crate::exit_status::handle_exit_status; +#[cfg(target_os = "macos")] +use seatbelt::DenialLogger; + #[cfg(target_os = "macos")] pub async fn run_command_under_seatbelt( command: SeatbeltCommand, @@ -22,6 +30,7 @@ pub async fn run_command_under_seatbelt( ) -> anyhow::Result<()> { let SeatbeltCommand { full_auto, + log_denials, config_overrides, command, } = command; @@ -31,6 +40,7 @@ pub async fn run_command_under_seatbelt( config_overrides, codex_linux_sandbox_exe, SandboxType::Seatbelt, + log_denials, ) .await } @@ -58,6 +68,7 @@ pub async fn run_command_under_landlock( config_overrides, codex_linux_sandbox_exe, SandboxType::Landlock, + false, ) .await } @@ -77,6 +88,7 @@ pub async fn run_command_under_windows( config_overrides, codex_linux_sandbox_exe, SandboxType::Windows, + false, ) .await } @@ -94,6 +106,7 @@ async fn run_command_under_sandbox( config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, sandbox_type: SandboxType, + log_denials: bool, ) -> anyhow::Result<()> { let sandbox_mode = create_sandbox_mode(full_auto); let config = Config::load_with_cli_overrides( @@ -180,6 +193,11 @@ async fn run_command_under_sandbox( } } + #[cfg(target_os = "macos")] + let mut denial_logger = log_denials.then(DenialLogger::new).flatten(); + #[cfg(not(target_os = "macos"))] + let _ = log_denials; + let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { @@ -213,8 +231,27 @@ async fn run_command_under_sandbox( unreachable!("Windows sandbox should have been handled above"); } }; + + #[cfg(target_os = "macos")] + if let Some(denial_logger) = &mut denial_logger { + denial_logger.on_child_spawn(&child); + } + let status = child.wait().await?; + #[cfg(target_os = "macos")] + if let Some(denial_logger) = denial_logger { + let denials = denial_logger.finish().await; + eprintln!("\n=== Sandbox denials ==="); + if denials.is_empty() { + eprintln!("None found."); + } else { + for seatbelt::SandboxDenial { name, capability } in denials { + eprintln!("({name}) {capability}"); + } + } + } + handle_exit_status(status); } diff --git a/codex-rs/cli/src/debug_sandbox/pid_tracker.rs b/codex-rs/cli/src/debug_sandbox/pid_tracker.rs new file mode 100644 index 0000000000..07bd2fd046 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox/pid_tracker.rs @@ -0,0 +1,372 @@ +use std::collections::HashSet; +use tokio::task::JoinHandle; +use tracing::warn; + +/// Tracks the (recursive) descendants of a process by using `kqueue` to watch for fork events, and +/// `proc_listchildpids` to list the children of a process. +pub(crate) struct PidTracker { + kq: libc::c_int, + handle: JoinHandle>, +} + +impl PidTracker { + pub(crate) fn new(root_pid: i32) -> Option { + if root_pid <= 0 { + return None; + } + + let kq = unsafe { libc::kqueue() }; + let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid)); + + Some(Self { kq, handle }) + } + + pub(crate) async fn stop(self) -> HashSet { + trigger_stop_event(self.kq); + self.handle.await.unwrap_or_default() + } +} + +unsafe extern "C" { + fn proc_listchildpids( + ppid: libc::c_int, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; +} + +/// Wrap proc_listchildpids. +fn list_child_pids(parent: i32) -> Vec { + unsafe { + let mut capacity: usize = 16; + loop { + let mut buf: Vec = vec![0; capacity]; + let count = proc_listchildpids( + parent as libc::c_int, + buf.as_mut_ptr() as *mut libc::c_void, + (buf.len() * std::mem::size_of::()) as libc::c_int, + ); + if count <= 0 { + return Vec::new(); + } + let returned = count as usize; + if returned < capacity { + buf.truncate(returned); + return buf; + } + capacity = capacity.saturating_mul(2).max(returned + 16); + } + } +} + +fn pid_is_alive(pid: i32) -> bool { + if pid <= 0 { + return false; + } + let res = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if res == 0 { + true + } else { + matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + } +} + +enum WatchPidError { + ProcessGone, + Other(std::io::Error), +} + +/// Add `pid` to the watch list in `kq`. +fn watch_pid(kq: libc::c_int, pid: i32) -> Result<(), WatchPidError> { + if pid <= 0 { + return Err(WatchPidError::ProcessGone); + } + + let kev = libc::kevent { + ident: pid as libc::uintptr_t, + filter: libc::EVFILT_PROC, + flags: libc::EV_ADD | libc::EV_CLEAR, + fflags: libc::NOTE_FORK | libc::NOTE_EXEC | libc::NOTE_EXIT, + data: 0, + udata: std::ptr::null_mut(), + }; + + let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; + if res < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + Err(WatchPidError::ProcessGone) + } else { + Err(WatchPidError::Other(err)) + } + } else { + Ok(()) + } +} + +fn watch_children( + kq: libc::c_int, + parent: i32, + seen: &mut HashSet, + active: &mut HashSet, +) { + for child_pid in list_child_pids(parent) { + add_pid_watch(kq, child_pid, seen, active); + } +} + +/// Watch `pid` and its children, updating `seen` and `active` sets. +fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet, active: &mut HashSet) { + if pid <= 0 { + return; + } + + let newly_seen = seen.insert(pid); + let mut should_recurse = newly_seen; + + if active.insert(pid) { + match watch_pid(kq, pid) { + Ok(()) => { + should_recurse = true; + } + Err(WatchPidError::ProcessGone) => { + active.remove(&pid); + return; + } + Err(WatchPidError::Other(err)) => { + warn!("failed to watch pid {pid}: {err}"); + active.remove(&pid); + return; + } + } + } + + if should_recurse { + watch_children(kq, pid, seen, active); + } +} +const STOP_IDENT: libc::uintptr_t = 1; + +fn register_stop_event(kq: libc::c_int) -> bool { + let kev = libc::kevent { + ident: STOP_IDENT, + filter: libc::EVFILT_USER, + flags: libc::EV_ADD | libc::EV_CLEAR, + fflags: 0, + data: 0, + udata: std::ptr::null_mut(), + }; + + let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; + res >= 0 +} + +fn trigger_stop_event(kq: libc::c_int) { + if kq < 0 { + return; + } + + let kev = libc::kevent { + ident: STOP_IDENT, + filter: libc::EVFILT_USER, + flags: 0, + fflags: libc::NOTE_TRIGGER, + data: 0, + udata: std::ptr::null_mut(), + }; + + let _ = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) }; +} + +/// Put all of the above together to track all the descendants of `root_pid`. +fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet { + if kq < 0 { + let mut seen = HashSet::new(); + seen.insert(root_pid); + return seen; + } + + if !register_stop_event(kq) { + let mut seen = HashSet::new(); + seen.insert(root_pid); + let _ = unsafe { libc::close(kq) }; + return seen; + } + + let mut seen: HashSet = HashSet::new(); + let mut active: HashSet = HashSet::new(); + + add_pid_watch(kq, root_pid, &mut seen, &mut active); + + const EVENTS_CAP: usize = 32; + let mut events: [libc::kevent; EVENTS_CAP] = + unsafe { std::mem::MaybeUninit::zeroed().assume_init() }; + + let mut stop_requested = false; + loop { + if active.is_empty() { + if !pid_is_alive(root_pid) { + break; + } + add_pid_watch(kq, root_pid, &mut seen, &mut active); + if active.is_empty() { + continue; + } + } + + let nev = unsafe { + libc::kevent( + kq, + std::ptr::null::(), + 0, + events.as_mut_ptr(), + EVENTS_CAP as libc::c_int, + std::ptr::null(), + ) + }; + + if nev < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + break; + } + + if nev == 0 { + continue; + } + + for ev in events.iter().take(nev as usize) { + let pid = ev.ident as i32; + + if ev.filter == libc::EVFILT_USER && ev.ident == STOP_IDENT { + stop_requested = true; + break; + } + + if (ev.flags & libc::EV_ERROR) != 0 { + if ev.data == libc::ESRCH as isize { + active.remove(&pid); + } + continue; + } + + if (ev.fflags & libc::NOTE_FORK) != 0 { + watch_children(kq, pid, &mut seen, &mut active); + } + + if (ev.fflags & libc::NOTE_EXIT) != 0 { + active.remove(&pid); + } + } + + if stop_requested { + break; + } + } + + let _ = unsafe { libc::close(kq) }; + + seen +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::process::Stdio; + use std::time::Duration; + + #[test] + fn pid_is_alive_detects_current_process() { + let pid = std::process::id() as i32; + assert!(pid_is_alive(pid)); + } + + #[cfg(target_os = "macos")] + #[test] + fn list_child_pids_includes_spawned_child() { + let mut child = Command::new("/bin/sleep") + .arg("5") + .stdin(Stdio::null()) + .spawn() + .expect("failed to spawn child process"); + + let child_pid = child.id() as i32; + let parent_pid = std::process::id() as i32; + + let mut found = false; + for _ in 0..100 { + if list_child_pids(parent_pid).contains(&child_pid) { + found = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + let _ = child.wait(); + + assert!(found, "expected to find child pid {child_pid} in list"); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pid_tracker_collects_spawned_children() { + let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + + let mut child = Command::new("/bin/sleep") + .arg("0.1") + .stdin(Stdio::null()) + .spawn() + .expect("failed to spawn child process"); + + let child_pid = child.id() as i32; + let parent_pid = std::process::id() as i32; + + let _ = child.wait(); + + let seen = tracker.stop().await; + + assert!( + seen.contains(&parent_pid), + "expected tracker to include parent pid {parent_pid}" + ); + assert!( + seen.contains(&child_pid), + "expected tracker to include child pid {child_pid}" + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn pid_tracker_collects_bash_subshell_descendants() { + let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker"); + + let child = Command::new("/bin/bash") + .arg("-c") + .arg("(sleep 0.1 & echo $!; wait)") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn bash"); + + let output = child.wait_with_output().unwrap().stdout; + let subshell_pid = String::from_utf8_lossy(&output) + .trim() + .parse::() + .expect("failed to parse subshell pid"); + + let seen = tracker.stop().await; + + assert!( + seen.contains(&subshell_pid), + "expected tracker to include subshell pid {subshell_pid}" + ); + } +} diff --git a/codex-rs/cli/src/debug_sandbox/seatbelt.rs b/codex-rs/cli/src/debug_sandbox/seatbelt.rs new file mode 100644 index 0000000000..a1d6435b51 --- /dev/null +++ b/codex-rs/cli/src/debug_sandbox/seatbelt.rs @@ -0,0 +1,114 @@ +use std::collections::HashSet; +use tokio::io::AsyncBufReadExt; +use tokio::process::Child; +use tokio::task::JoinHandle; + +use super::pid_tracker::PidTracker; + +pub struct SandboxDenial { + pub name: String, + pub capability: String, +} + +pub struct DenialLogger { + log_stream: Child, + pid_tracker: Option, + log_reader: Option>>, +} + +impl DenialLogger { + pub(crate) fn new() -> Option { + let mut log_stream = start_log_stream()?; + let stdout = log_stream.stdout.take()?; + let log_reader = tokio::spawn(async move { + let mut reader = tokio::io::BufReader::new(stdout); + let mut logs = Vec::new(); + let mut chunk = Vec::new(); + loop { + match reader.read_until(b'\n', &mut chunk).await { + Ok(0) | Err(_) => break, + Ok(_) => { + logs.extend_from_slice(&chunk); + chunk.clear(); + } + } + } + logs + }); + + Some(Self { + log_stream, + pid_tracker: None, + log_reader: Some(log_reader), + }) + } + + pub(crate) fn on_child_spawn(&mut self, child: &Child) { + if let Some(root_pid) = child.id() { + self.pid_tracker = PidTracker::new(root_pid as i32); + } + } + + pub(crate) async fn finish(mut self) -> Vec { + let pid_set = match self.pid_tracker { + Some(tracker) => tracker.stop().await, + None => Default::default(), + }; + + if pid_set.is_empty() { + return Vec::new(); + } + + let _ = self.log_stream.kill().await; + let _ = self.log_stream.wait().await; + + let logs_bytes = match self.log_reader.take() { + Some(handle) => handle.await.unwrap_or_default(), + None => Vec::new(), + }; + let logs = String::from_utf8_lossy(&logs_bytes); + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut denials: Vec = Vec::new(); + for line in logs.lines() { + if let Ok(json) = serde_json::from_str::(line) + && let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str()) + && let Some((pid, name, capability)) = parse_message(msg) + && pid_set.contains(&pid) + && seen.insert((name.clone(), capability.clone())) + { + denials.push(SandboxDenial { name, capability }); + } + } + denials + } +} + +fn start_log_stream() -> Option { + use std::process::Stdio; + + const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#; + + tokio::process::Command::new("log") + .args(["stream", "--style", "ndjson", "--predicate", PREDICATE]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .ok() +} + +fn parse_message(msg: &str) -> Option<(i32, String, String)> { + // Example message: + // Sandbox: processname(1234) deny(1) capability-name args... + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + #[expect(clippy::unwrap_used)] + regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap() + }); + + let (_, [name, pid_str, capability]) = re.captures(msg)?.extract(); + let pid = pid_str.trim().parse::().ok()?; + Some((pid, name.to_string(), capability.to_string())) +} diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 0cd2789663..e9f60eba76 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -11,6 +11,10 @@ pub struct SeatbeltCommand { #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, + /// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit + #[arg(long = "log-denials", default_value_t = false)] + pub log_denials: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, From 60deb6773a353831eb9f41b753e7cd7279ce23de Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 10 Nov 2025 15:13:43 -0800 Subject: [PATCH 106/122] refactor(tui): job-control for Ctrl-Z handling (#6477) - Moved the unix-only suspend/resume logic into a dedicated job_control module housing SuspendContext, replacing scattered cfg-gated fields and helpers in tui.rs. - Tui now holds a single suspend_context (Arc-backed) instead of multiple atomics, and the event stream uses it directly for Ctrl-Z handling. - Added detailed docs around the suspend/resume flow, cursor tracking, and the Arc/atomic ownership model for the 'static event stream. - Renamed the process-level SIGTSTP helper to suspend_process and the cursor tracker to set_cursor_y to better reflect their roles. --- codex-rs/tui/src/tui.rs | 172 +++++--------------------- codex-rs/tui/src/tui/job_control.rs | 182 ++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 142 deletions(-) create mode 100644 codex-rs/tui/src/tui/job_control.rs diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 39849471b5..d758996d26 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,29 +1,23 @@ +use std::fmt; use std::io::IsTerminal; use std::io::Result; use std::io::Stdout; use std::io::stdout; +use std::panic; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; -#[cfg(unix)] -use std::sync::atomic::AtomicU8; -#[cfg(unix)] -use std::sync::atomic::AtomicU16; use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; use crossterm::Command; use crossterm::SynchronizedUpdate; -#[cfg(unix)] -use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; use crossterm::event::Event; -#[cfg(unix)] -use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; @@ -38,20 +32,22 @@ use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; use ratatui::layout::Offset; use ratatui::text::Line; +use tokio::select; +use tokio_stream::Stream; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; #[cfg(unix)] -use crate::key_hint; -use tokio::select; -use tokio_stream::Stream; +use crate::tui::job_control::SUSPEND_KEY; +#[cfg(unix)] +use crate::tui::job_control::SuspendContext; + +#[cfg(unix)] +mod job_control; /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; -#[cfg(unix)] -const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); - pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -79,12 +75,12 @@ pub fn set_modes() -> Result<()> { struct EnableAlternateScroll; impl Command for EnableAlternateScroll { - fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { write!(f, "\x1b[?1007h") } #[cfg(windows)] - fn execute_winapi(&self) -> std::io::Result<()> { + fn execute_winapi(&self) -> Result<()> { Err(std::io::Error::other( "tried to execute EnableAlternateScroll using WinAPI; use ANSI instead", )) @@ -100,12 +96,12 @@ impl Command for EnableAlternateScroll { struct DisableAlternateScroll; impl Command for DisableAlternateScroll { - fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { write!(f, "\x1b[?1007l") } #[cfg(windows)] - fn execute_winapi(&self) -> std::io::Result<()> { + fn execute_winapi(&self) -> Result<()> { Err(std::io::Error::other( "tried to execute DisableAlternateScroll using WinAPI; use ANSI instead", )) @@ -144,8 +140,8 @@ pub fn init() -> Result { } fn set_panic_hook() { - let hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { + let hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic_info| { let _ = restore(); // ignore any errors as we are already failing hook(panic_info); })); @@ -165,9 +161,7 @@ pub struct Tui { pending_history_lines: Vec>, alt_saved_viewport: Option, #[cfg(unix)] - resume_pending: Arc, // Stores a ResumeAction - #[cfg(unix)] - suspend_cursor_y: Arc, // Bottom line of inline viewport + suspend_context: SuspendContext, // True when overlay alt-screen UI is active alt_screen_active: Arc, // True when terminal/tab is focused; updated internally from crossterm events @@ -175,30 +169,6 @@ pub struct Tui { enhanced_keys_supported: bool, } -#[cfg(unix)] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -#[repr(u8)] -enum ResumeAction { - None = 0, - RealignInline = 1, - RestoreAlt = 2, -} - -#[cfg(unix)] -enum PreparedResumeAction { - RestoreAltScreen, - RealignViewport(ratatui::layout::Rect), -} - -#[cfg(unix)] -fn take_resume_action(pending: &AtomicU8) -> ResumeAction { - match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) { - 1 => ResumeAction::RealignInline, - 2 => ResumeAction::RestoreAlt, - _ => ResumeAction::None, - } -} - #[derive(Clone, Debug)] pub struct FrameRequester { frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, @@ -244,9 +214,7 @@ impl Tui { pending_history_lines: vec![], alt_saved_viewport: None, #[cfg(unix)] - resume_pending: Arc::new(AtomicU8::new(0)), - #[cfg(unix)] - suspend_cursor_y: Arc::new(AtomicU16::new(0)), + suspend_context: SuspendContext::new(), alt_screen_active: Arc::new(AtomicBool::new(false)), terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, @@ -282,26 +250,9 @@ impl Tui { // State for tracking how we should resume from ^Z suspend. #[cfg(unix)] - let resume_pending = self.resume_pending.clone(); + let suspend_context = self.suspend_context.clone(); #[cfg(unix)] let alt_screen_active = self.alt_screen_active.clone(); - #[cfg(unix)] - let suspend_cursor_y = self.suspend_cursor_y.clone(); - - #[cfg(unix)] - let suspend = move || { - if alt_screen_active.load(Ordering::Relaxed) { - // Disable alternate scroll when suspending from alt-screen - let _ = execute!(stdout(), DisableAlternateScroll); - let _ = execute!(stdout(), LeaveAlternateScreen); - resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed); - } else { - resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed); - } - let y = suspend_cursor_y.load(Ordering::Relaxed); - let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show); - let _ = Tui::suspend(); - }; let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { @@ -309,10 +260,10 @@ impl Tui { select! { Some(Ok(event)) = crossterm_events.next() => { match event { - crossterm::event::Event::Key(key_event) => { + Event::Key(key_event) => { #[cfg(unix)] if SUSPEND_KEY.is_press(key_event) { - suspend(); + let _ = suspend_context.suspend(&alt_screen_active); // We continue here after resume. yield TuiEvent::Draw; continue; @@ -356,67 +307,6 @@ impl Tui { Box::pin(event_stream) } - #[cfg(unix)] - fn suspend() -> Result<()> { - restore()?; - unsafe { libc::kill(0, libc::SIGTSTP) }; - set_modes()?; - Ok(()) - } - - /// When resuming from ^Z suspend, we want to put things back the way they were before suspend. - /// We capture the action in an object so we can pass it into the event stream, since the relevant - #[cfg(unix)] - fn prepare_resume_action( - &mut self, - action: ResumeAction, - ) -> Result> { - match action { - ResumeAction::RealignInline => { - let cursor_pos = self - .terminal - .get_cursor_position() - .unwrap_or(self.terminal.last_known_cursor_pos); - Ok(Some(PreparedResumeAction::RealignViewport( - ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0), - ))) - } - ResumeAction::RestoreAlt => { - if let Ok(ratatui::layout::Position { y, .. }) = self.terminal.get_cursor_position() - && let Some(saved) = self.alt_saved_viewport.as_mut() - { - saved.y = y; - } - Ok(Some(PreparedResumeAction::RestoreAltScreen)) - } - ResumeAction::None => Ok(None), - } - } - - #[cfg(unix)] - fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> { - match prepared { - PreparedResumeAction::RealignViewport(area) => { - self.terminal.set_viewport_area(area); - } - PreparedResumeAction::RestoreAltScreen => { - execute!(self.terminal.backend_mut(), EnterAlternateScreen)?; - // Enable "alternate scroll" so terminals may translate wheel to arrows - execute!(self.terminal.backend_mut(), EnableAlternateScroll)?; - if let Ok(size) = self.terminal.size() { - self.terminal.set_viewport_area(ratatui::layout::Rect::new( - 0, - 0, - size.width, - size.height, - )); - self.terminal.clear()?; - } - } - } - Ok(()) - } - /// Enter alternate screen and expand the viewport to full terminal size, saving the current /// inline viewport for restoration when leaving. pub fn enter_alt_screen(&mut self) -> Result<()> { @@ -462,8 +352,9 @@ impl Tui { // If we are resuming from ^Z, we need to prepare the resume action now so we can apply it // in the synchronized update. #[cfg(unix)] - let mut prepared_resume = - self.prepare_resume_action(take_resume_action(&self.resume_pending))?; + let mut prepared_resume = self + .suspend_context + .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); // Precompute any viewport updates that need a cursor-position query before entering // the synchronized update, to avoid racing with the event reader. @@ -490,12 +381,10 @@ impl Tui { } } - std::io::stdout().sync_update(|_| { + stdout().sync_update(|_| { #[cfg(unix)] - { - if let Some(prepared) = prepared_resume.take() { - self.apply_prepared_resume_action(prepared)?; - } + if let Some(prepared) = prepared_resume.take() { + prepared.apply(&mut self.terminal)?; } let terminal = &mut self.terminal; if let Some(new_area) = pending_viewport_area.take() { @@ -539,8 +428,7 @@ impl Tui { } else { area.bottom().saturating_sub(1) }; - self.suspend_cursor_y - .store(inline_area_bottom, Ordering::Relaxed); + self.suspend_context.set_cursor_y(inline_area_bottom); } terminal.draw(|frame| { @@ -600,12 +488,12 @@ fn spawn_frame_scheduler( pub struct PostNotification(pub String); impl Command for PostNotification { - fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { write!(f, "\x1b]9;{}\x07", self.0) } #[cfg(windows)] - fn execute_winapi(&self) -> std::io::Result<()> { + fn execute_winapi(&self) -> Result<()> { Err(std::io::Error::other( "tried to execute PostNotification using WinAPI; use ANSI instead", )) diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs new file mode 100644 index 0000000000..3680419489 --- /dev/null +++ b/codex-rs/tui/src/tui/job_control.rs @@ -0,0 +1,182 @@ +use std::io::Result; +use std::io::stdout; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::Ordering; + +use crossterm::cursor::MoveTo; +use crossterm::cursor::Show; +use crossterm::event::KeyCode; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; +use ratatui::crossterm::execute; +use ratatui::layout::Position; +use ratatui::layout::Rect; + +use crate::key_hint; + +use super::DisableAlternateScroll; +use super::EnableAlternateScroll; +use super::Terminal; + +pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); + +/// Coordinates suspend/resume handling so the TUI can restore terminal context after SIGTSTP. +/// +/// On suspend, it records which resume path to take (realign inline viewport vs. restore alt +/// screen) and caches the inline cursor row so the cursor can be placed meaningfully before +/// yielding. +/// +/// After resume, `prepare_resume_action` consumes the pending intent and returns a +/// `PreparedResumeAction` describing any viewport adjustments to apply inside the synchronized +/// draw. +/// +/// Callers keep `suspend_cursor_y` up to date during normal drawing so the suspend step always +/// has the latest cursor position. +/// +/// The type is `Clone`, using Arc/atomic internals so bookkeeping can be shared across tasks +/// and moved into the boxed `'static` event stream without borrowing `self`. +#[derive(Clone)] +pub struct SuspendContext { + /// Resume intent captured at suspend time; cleared once applied after resume. + resume_pending: Arc>>, + /// Inline viewport cursor row used to place the cursor before yielding during suspend. + suspend_cursor_y: Arc, +} + +impl SuspendContext { + pub(crate) fn new() -> Self { + Self { + resume_pending: Arc::new(Mutex::new(None)), + suspend_cursor_y: Arc::new(AtomicU16::new(0)), + } + } + + /// Capture how to resume, stash cursor position, and temporarily yield during SIGTSTP. + /// + /// - If the alt screen is active, exit alt-scroll/alt-screen and record `RestoreAlt`; + /// otherwise record `RealignInline`. + /// - Update the cached inline cursor row so suspend can place the cursor meaningfully. + /// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state. + pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { + if alt_screen_active.load(Ordering::Relaxed) { + // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. + let _ = execute!(stdout(), DisableAlternateScroll); + let _ = execute!(stdout(), LeaveAlternateScreen); + self.set_resume_action(ResumeAction::RestoreAlt); + } else { + self.set_resume_action(ResumeAction::RealignInline); + } + let y = self.suspend_cursor_y.load(Ordering::Relaxed); + let _ = execute!(stdout(), MoveTo(0, y), Show); + suspend_process() + } + + /// Consume the pending resume intent and precompute any viewport changes needed post-resume. + /// + /// Returns a `PreparedResumeAction` describing how to realign the viewport once drawing + /// resumes; returns `None` when there was no pending suspend intent. + pub(crate) fn prepare_resume_action( + &self, + terminal: &mut Terminal, + alt_saved_viewport: &mut Option, + ) -> Option { + let action = self.take_resume_action()?; + match action { + ResumeAction::RealignInline => { + let cursor_pos = terminal + .get_cursor_position() + .unwrap_or(terminal.last_known_cursor_pos); + let viewport = Rect::new(0, cursor_pos.y, 0, 0); + Some(PreparedResumeAction::RealignViewport(viewport)) + } + ResumeAction::RestoreAlt => { + if let Ok(Position { y, .. }) = terminal.get_cursor_position() + && let Some(saved) = alt_saved_viewport.as_mut() + { + saved.y = y; + } + Some(PreparedResumeAction::RestoreAltScreen) + } + } + } + + /// Set the cached inline cursor row so suspend can place the cursor meaningfully. + /// + /// Call during normal drawing when the inline viewport moves so suspend has a fresh cursor + /// position to restore before yielding. + pub(crate) fn set_cursor_y(&self, value: u16) { + self.suspend_cursor_y.store(value, Ordering::Relaxed); + } + + /// Record a pending resume action to apply after SIGTSTP returns control. + fn set_resume_action(&self, value: ResumeAction) { + *self + .resume_pending + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(value); + } + + /// Take and clear any pending resume action captured at suspend time. + fn take_resume_action(&self) -> Option { + self.resume_pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + } +} + +/// Captures what should happen when returning from suspend. +/// +/// Either realign the inline viewport to keep the cursor position, or re-enter the alt screen +/// to restore the overlay UI. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResumeAction { + /// Shift the inline viewport to keep the cursor anchored after resume. + RealignInline, + /// Re-enter the alt screen and restore the overlay UI. + RestoreAlt, +} + +/// Describes the viewport change to apply when resuming from suspend during the synchronized draw. +/// +/// Either restore the alt screen (with viewport reset) or realign the inline viewport. +#[derive(Clone, Debug)] +pub(crate) enum PreparedResumeAction { + /// Re-enter the alt screen and reset the viewport to the terminal dimensions. + RestoreAltScreen, + /// Apply a viewport shift to keep the inline cursor position stable. + RealignViewport(Rect), +} + +impl PreparedResumeAction { + pub(crate) fn apply(self, terminal: &mut Terminal) -> Result<()> { + match self { + PreparedResumeAction::RealignViewport(area) => { + terminal.set_viewport_area(area); + } + PreparedResumeAction::RestoreAltScreen => { + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + // Enable "alternate scroll" so terminals may translate wheel to arrows + execute!(terminal.backend_mut(), EnableAlternateScroll)?; + if let Ok(size) = terminal.size() { + terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height)); + terminal.clear()?; + } + } + } + Ok(()) + } +} + +/// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed. +fn suspend_process() -> Result<()> { + super::restore()?; + unsafe { libc::kill(0, libc::SIGTSTP) }; + // After the process resumes, reapply terminal modes so drawing can continue. + super::set_modes()?; + Ok(()) +} From 3838d6739cd3e2b81c549df5767289f47e0c836e Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 10 Nov 2025 15:15:08 -0800 Subject: [PATCH 107/122] [app-server] update macro to make renaming methods less boilerplate-y (#6470) We already do this for notification definitions and it's really nice. Verified there are no changes to actual exported files by diff'ing before and after this change. --- .../src/protocol/common.rs | 59 +++++-------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b4cde11000..f754ece562 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -46,7 +46,7 @@ macro_rules! client_request_definitions { ( $( $(#[$variant_meta:meta])* - $variant:ident { + $variant:ident $(=> $wire:literal)? { params: $(#[$params_meta:meta])* $params:ty, response: $response:ty, } @@ -58,6 +58,7 @@ macro_rules! client_request_definitions { pub enum ClientRequest { $( $(#[$variant_meta])* + $(#[serde(rename = $wire)] #[ts(rename = $wire)])? $variant { #[serde(rename = "id")] request_id: RequestId, @@ -108,94 +109,66 @@ client_request_definitions! { /// NEW APIs // Thread lifecycle - #[serde(rename = "thread/start")] - #[ts(rename = "thread/start")] - ThreadStart { + ThreadStart => "thread/start" { params: v2::ThreadStartParams, response: v2::ThreadStartResponse, }, - #[serde(rename = "thread/resume")] - #[ts(rename = "thread/resume")] - ThreadResume { + ThreadResume => "thread/resume" { params: v2::ThreadResumeParams, response: v2::ThreadResumeResponse, }, - #[serde(rename = "thread/archive")] - #[ts(rename = "thread/archive")] - ThreadArchive { + ThreadArchive => "thread/archive" { params: v2::ThreadArchiveParams, response: v2::ThreadArchiveResponse, }, - #[serde(rename = "thread/list")] - #[ts(rename = "thread/list")] - ThreadList { + ThreadList => "thread/list" { params: v2::ThreadListParams, response: v2::ThreadListResponse, }, - #[serde(rename = "thread/compact")] - #[ts(rename = "thread/compact")] - ThreadCompact { + ThreadCompact => "thread/compact" { params: v2::ThreadCompactParams, response: v2::ThreadCompactResponse, }, - #[serde(rename = "turn/start")] - #[ts(rename = "turn/start")] - TurnStart { + TurnStart => "turn/start" { params: v2::TurnStartParams, response: v2::TurnStartResponse, }, - #[serde(rename = "turn/interrupt")] - #[ts(rename = "turn/interrupt")] - TurnInterrupt { + TurnInterrupt => "turn/interrupt" { params: v2::TurnInterruptParams, response: v2::TurnInterruptResponse, }, - #[serde(rename = "model/list")] - #[ts(rename = "model/list")] - ModelList { + ModelList => "model/list" { params: v2::ModelListParams, response: v2::ModelListResponse, }, - #[serde(rename = "account/login/start")] - #[ts(rename = "account/login/start")] - LoginAccount { + LoginAccount => "account/login/start" { params: v2::LoginAccountParams, response: v2::LoginAccountResponse, }, - #[serde(rename = "account/login/cancel")] - #[ts(rename = "account/login/cancel")] - CancelLoginAccount { + CancelLoginAccount => "account/login/cancel" { params: v2::CancelLoginAccountParams, response: v2::CancelLoginAccountResponse, }, - #[serde(rename = "account/logout")] - #[ts(rename = "account/logout")] - LogoutAccount { + LogoutAccount => "account/logout" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v2::LogoutAccountResponse, }, - #[serde(rename = "account/rateLimits/read")] - #[ts(rename = "account/rateLimits/read")] - GetAccountRateLimits { + GetAccountRateLimits => "account/rateLimits/read" { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v2::GetAccountRateLimitsResponse, }, - #[serde(rename = "feedback/upload")] - #[ts(rename = "feedback/upload")] - FeedbackUpload { + FeedbackUpload => "feedback/upload" { params: v2::FeedbackUploadParams, response: v2::FeedbackUploadResponse, }, - #[serde(rename = "account/read")] - #[ts(rename = "account/read")] - GetAccount { + GetAccount => "account/read" { params: v2::GetAccountParams, response: v2::GetAccountResponse, }, From 9aff64e017f23a747da29e32b218cf4d0240b2e2 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 10 Nov 2025 15:31:06 -0800 Subject: [PATCH 108/122] upload Windows .exe file artifacts for CLI releases (#6478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is to unlock future WinGet installation. WinGet struggles to create command aliases when installing from nested ZIPs on some clients, so adding raw Windows x64/Arm64 executables lets the manifest use InstallerType: portable with direct EXEs, which reliably registers the codex alias. This makes “winget install → codex” work out of the box without PATH changes. --------- Co-authored-by: Michael Bolin --- .github/workflows/rust-release.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 26afdaf552..6f27fbf543 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -295,6 +295,15 @@ jobs: # ${{ matrix.target }} dest="dist/${{ matrix.target }}" + # We want to ship the raw Windows executables in the GitHub Release + # in addition to the compressed archives. Keep the originals for + # Windows targets; remove them elsewhere to limit the number of + # artifacts that end up in the GitHub Release. + keep_originals=false + if [[ "${{ matrix.runner }}" == windows* ]]; then + keep_originals=true + fi + # For compatibility with environments that lack the `zstd` tool we # additionally create a `.tar.gz` for all platforms and `.zip` for # Windows alongside every single binary that we publish. The end result is: @@ -324,7 +333,11 @@ jobs: # Also create .zst (existing behaviour) *and* remove the original # uncompressed binary to keep the directory small. - zstd -T0 -19 --rm "$dest/$base" + zstd_args=(-T0 -19) + if [[ "${keep_originals}" == false ]]; then + zstd_args+=(--rm) + fi + zstd "${zstd_args[@]}" "$dest/$base" done - name: Remove signing keychain From 930f81a17b0051404c181a5cfe18c928b0a20121 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Mon, 10 Nov 2025 17:13:10 -0800 Subject: [PATCH 109/122] flip rate limit status bar (#6482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flipping rate limit status bar to match chat.com/codex/settings/usage Screenshot 2025-11-10 at 4 53 41 PM --- codex-rs/tui/src/status/card.rs | 5 +++-- codex-rs/tui/src/status/rate_limits.rs | 8 ++++---- ...us__tests__status_snapshot_includes_monthly_limit.snap | 2 +- ...tests__status_snapshot_includes_reasoning_details.snap | 4 ++-- ...tests__status_snapshot_shows_stale_limits_message.snap | 4 ++-- ...sts__status_snapshot_truncates_in_narrow_terminal.snap | 3 +-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 11b723fef9..977f913d17 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -211,10 +211,11 @@ impl StatusHistoryCell { let mut lines = Vec::with_capacity(rows.len().saturating_mul(2)); for row in rows { + let percent_remaining = (100.0 - row.percent_used).clamp(0.0, 100.0); let value_spans = vec![ - Span::from(render_status_limit_progress_bar(row.percent_used)), + Span::from(render_status_limit_progress_bar(percent_remaining)), Span::from(" "), - Span::from(format_status_limit_summary(row.percent_used)), + Span::from(format_status_limit_summary(percent_remaining)), ]; let base_spans = formatter.full_spans(row.label.as_str(), value_spans); let base_line = Line::from(base_spans.clone()); diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 4c08cdd673..50cbd97793 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -124,8 +124,8 @@ pub(crate) fn compose_rate_limit_data( } } -pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String { - let ratio = (percent_used / 100.0).clamp(0.0, 1.0); +pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String { + let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0); let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); @@ -136,8 +136,8 @@ pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String { ) } -pub(crate) fn format_status_limit_summary(percent_used: f64) -> String { - format!("{percent_used:.0}% used") +pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String { + format!("{percent_remaining:.0}% left") } fn capitalize_first(label: &str) -> String { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index 85e6356c34..66484261b9 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -18,5 +18,5 @@ expression: sanitized │ │ │ Token usage: 1.2K total (800 input + 400 output) │ │ Context window: 100% left (1.2K used / 272K) │ -│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │ +│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ ╰────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 123547cb56..4dc951db58 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -18,6 +18,6 @@ expression: sanitized │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ -│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │ ╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index 3a98ce95ca..7d548f4b2e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -18,7 +18,7 @@ expression: sanitized │ │ │ Token usage: 1.9K total (1K input + 900 output) │ │ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ -│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ │ Warning: limits may be stale - start new turn to refresh. │ ╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 5bbaaef7cd..ea5444d6d8 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -1,6 +1,5 @@ --- source: tui/src/status/tests.rs -assertion_line: 257 expression: sanitized --- /status @@ -20,6 +19,6 @@ expression: sanitized │ │ │ Token usage: 1.9K total (1K input + │ │ Context window: 100% left (2.25K used / │ -│ 5h limit: [███████████████░░░░░] │ +│ 5h limit: [██████░░░░░░░░░░░░░░] │ │ (resets 03:14) │ ╰────────────────────────────────────────────╯ From 6c36318bd8f6286f907bc0a3bb2604e761360cb0 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 10 Nov 2025 17:17:09 -0800 Subject: [PATCH 110/122] Use codex-linux-sandbox in unified exec (#6480) Unified exec isn't working on Linux because we don't provide the correct arg0. The library we use for pty management doesn't allow setting arg0 separately from executable. Use the same aliasing strategy we use for `apply_patch` for `codex-linux-sandbox`. Use `#[ctor]` hack to dispatch codex-linux-sandbox calls. Addresses https://github.com/openai/codex/issues/6450 --- codex-rs/Cargo.lock | 2 + codex-rs/arg0/src/lib.rs | 75 ++++++++++-------- codex-rs/core/Cargo.toml | 4 +- .../core/src/unified_exec/session_manager.rs | 14 +++- codex-rs/core/tests/suite/mod.rs | 13 ++++ codex-rs/core/tests/suite/unified_exec.rs | 76 ++++++++++++++++++- codex-rs/utils/pty/src/lib.rs | 3 +- 7 files changed, 147 insertions(+), 40 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2ed86153f8..cf5f961a62 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1067,6 +1067,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", + "codex-arg0", "codex-async-utils", "codex-file-search", "codex-git", @@ -1081,6 +1082,7 @@ dependencies = [ "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", + "ctor 0.5.0", "dirs", "dunce", "env-flags", diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index e70ff2df64..6b60536413 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -11,32 +11,7 @@ const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox"; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; -/// While we want to deploy the Codex CLI as a single executable for simplicity, -/// we also want to expose some of its functionality as distinct CLIs, so we use -/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows -/// us to simulate deploying multiple executables as a single binary on Mac and -/// Linux (but not Windows). -/// -/// When the current executable is invoked through the hard-link or alias named -/// `codex-linux-sandbox` we *directly* execute -/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: -/// -/// 1. Load `.env` values from `~/.codex/.env` before creating any threads. -/// 2. Construct a Tokio multi-thread runtime. -/// 3. Derive the path to the current executable (so children can re-invoke the -/// sandbox) when running on Linux. -/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any -/// error. Note that `main_fn` receives `codex_linux_sandbox_exe: -/// Option`, as an argument, which is generally needed as part of -/// constructing [`codex_core::config::Config`]. -/// -/// This function should be used to wrap any `main()` function in binary crates -/// in this workspace that depends on these helper CLIs. -pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> -where - F: FnOnce(Option) -> Fut, - Fut: Future>, -{ +pub fn arg0_dispatch() -> Option { // Determine if we were invoked via the special alias. let mut args = std::env::args_os(); let argv0 = args.next().unwrap_or_default(); @@ -76,10 +51,7 @@ where // before creating any threads/the Tokio runtime. load_dotenv(); - // Retain the TempDir so it exists for the lifetime of the invocation of - // this executable. Admittedly, we could invoke `keep()` on it, but it - // would be nice to avoid leaving temporary directories behind, if possible. - let _path_entry = match prepend_path_entry_for_apply_patch() { + match prepend_path_entry_for_codex_aliases() { Ok(path_entry) => Some(path_entry), Err(err) => { // It is possible that Codex will proceed successfully even if @@ -87,7 +59,39 @@ where eprintln!("WARNING: proceeding, even though we could not update PATH: {err}"); None } - }; + } +} + +/// While we want to deploy the Codex CLI as a single executable for simplicity, +/// we also want to expose some of its functionality as distinct CLIs, so we use +/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows +/// us to simulate deploying multiple executables as a single binary on Mac and +/// Linux (but not Windows). +/// +/// When the current executable is invoked through the hard-link or alias named +/// `codex-linux-sandbox` we *directly* execute +/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: +/// +/// 1. Load `.env` values from `~/.codex/.env` before creating any threads. +/// 2. Construct a Tokio multi-thread runtime. +/// 3. Derive the path to the current executable (so children can re-invoke the +/// sandbox) when running on Linux. +/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any +/// error. Note that `main_fn` receives `codex_linux_sandbox_exe: +/// Option`, as an argument, which is generally needed as part of +/// constructing [`codex_core::config::Config`]. +/// +/// This function should be used to wrap any `main()` function in binary crates +/// in this workspace that depends on these helper CLIs. +pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> +where + F: FnOnce(Option) -> Fut, + Fut: Future>, +{ + // Retain the TempDir so it exists for the lifetime of the invocation of + // this executable. Admittedly, we could invoke `keep()` on it, but it + // would be nice to avoid leaving temporary directories behind, if possible. + let _path_entry = arg0_dispatch(); // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. @@ -144,11 +148,16 @@ where /// /// IMPORTANT: This function modifies the PATH environment variable, so it MUST /// be called before multiple threads are spawned. -fn prepend_path_entry_for_apply_patch() -> std::io::Result { +pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { let temp_dir = TempDir::new()?; let path = temp_dir.path(); - for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] { + for filename in &[ + APPLY_PATCH_ARG0, + MISSPELLED_APPLY_PATCH_ARG0, + #[cfg(target_os = "linux")] + LINUX_SANDBOX_ARG0, + ] { let exe = std::env::current_exe()?; #[cfg(unix)] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 17a7a16609..6839504be5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -32,6 +32,7 @@ codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-utils-tokenizer = { workspace = true } +codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } @@ -83,7 +84,6 @@ tree-sitter-bash = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } -codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } [target.'cfg(target_os = "linux")'.dependencies] @@ -104,7 +104,9 @@ openssl-sys = { workspace = true, features = ["vendored"] } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +codex-arg0 = { workspace = true } core_test_support = { workspace = true } +ctor = { workspace = true } escargot = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } maplit = { workspace = true } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index bc10fc2bd8..55a5f589a3 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -300,10 +300,16 @@ impl UnifiedExecSessionManager { .command .split_first() .ok_or(UnifiedExecError::MissingCommandLine)?; - let spawned = - codex_utils_pty::spawn_pty_process(program, args, env.cwd.as_path(), &env.env) - .await - .map_err(|err| UnifiedExecError::create_session(err.to_string()))?; + + let spawned = codex_utils_pty::spawn_pty_process( + program, + args, + env.cwd.as_path(), + &env.env, + &env.arg0, + ) + .await + .map_err(|err| UnifiedExecError::create_session(err.to_string()))?; UnifiedExecSession::from_spawned(spawned, env.sandbox).await } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a884167471..a19bfa2505 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,4 +1,17 @@ // Aggregates all former standalone integration tests as modules. +use codex_arg0::arg0_dispatch; +use ctor::ctor; +use tempfile::TempDir; + +// This code runs before any other tests are run. +// It allows the test binary to behave like codex and dispatch to apply_patch and codex-linux-sandbox +// based on the arg0. +// NOTE: this doesn't work on ARM +#[ctor] +pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe { + #[allow(clippy::unwrap_used)] + arg0_dispatch().unwrap() +}; #[cfg(not(target_os = "windows"))] mod abort_tasks; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index cd8b338200..1225b51ccd 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -66,7 +66,7 @@ fn parse_unified_exec_output(raw: &str) -> Result { let cleaned = raw.trim_matches('\r'); let captures = regex .captures(cleaned) - .ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output"))?; + .ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output {raw}"))?; let chunk_id = captures .name("chunk_id") @@ -1368,6 +1368,8 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// Skipped on arm because the ctor logic to handle arg0 doesn't work on ARM +#[cfg(not(target_arch = "arm"))] async fn unified_exec_formats_large_output_summary() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -1451,3 +1453,75 @@ PY Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_runs_under_sandbox() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec"; + let args = serde_json::json!({ + "cmd": "echo 'hello'", + "yield_time_ms": 500, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "summarize large output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + // Important! + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs.get(call_id).expect("missing output"); + + assert_regex_match("hello[\r\n]+", &output.output); + + Ok(()) +} diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 740a502ba9..14cc430760 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -111,6 +111,7 @@ pub async fn spawn_pty_process( args: &[String], cwd: &Path, env: &HashMap, + arg0: &Option, ) -> Result { if program.is_empty() { anyhow::bail!("missing program for PTY spawn"); @@ -124,7 +125,7 @@ pub async fn spawn_pty_process( pixel_height: 0, })?; - let mut command_builder = CommandBuilder::new(program); + let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string())); command_builder.cwd(cwd); command_builder.env_clear(); for arg in args { From bb7b0213a8cf47ed55515667d1b84a645c9ccb1a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 10 Nov 2025 18:38:36 -0800 Subject: [PATCH 111/122] Colocate more of bash parsing (#6489) Move a few callsites that were detecting `bash -lc` into a shared helper. --- codex-rs/core/src/bash.rs | 30 +++++++++++++++++----- codex-rs/core/src/parse_command.rs | 41 ++++++++++++++++++++++-------- codex-rs/tui/src/exec_command.rs | 15 +++-------- 3 files changed, 57 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 07fc125f16..e85ce76a5e 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -88,17 +88,33 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option bool { + if shell == "bash" || shell == "zsh" { + return true; + } + + let shell_name = std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell); + matches!(shell_name, "bash" | "zsh") +} + +pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { + let [shell, flag, script] = command else { + return None; + }; + if flag != "-lc" || !is_well_known_sh_shell(shell) { + return None; + } + Some((shell, script)) +} + /// Returns the sequence of plain commands within a `bash -lc "..."` or /// `zsh -lc "..."` invocation when the script only contains word-only commands /// joined by safe operators. pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option>> { - let [shell, flag, script] = command else { - return None; - }; - - if flag != "-lc" || !(shell == "bash" || shell == "zsh") { - return None; - } + let (_, script) = extract_bash_command(command)?; let tree = try_parse_shell(script)?; try_parse_word_only_commands_sequence(&tree, script) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 53402f880d..61454db462 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1,3 +1,4 @@ +use crate::bash::extract_bash_command; use crate::bash::try_parse_shell; use crate::bash::try_parse_word_only_commands_sequence; use codex_protocol::parse_command::ParsedCommand; @@ -853,6 +854,29 @@ mod tests { }], ); } + + #[test] + fn bin_bash_lc_sed() { + assert_parsed( + &shlex_split_safe("/bin/bash -lc 'sed -n '1,10p' Cargo.toml'"), + vec![ParsedCommand::Read { + cmd: "sed -n '1,10p' Cargo.toml".to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } + #[test] + fn bin_zsh_lc_sed() { + assert_parsed( + &shlex_split_safe("/bin/zsh -lc 'sed -n '1,10p' Cargo.toml'"), + vec![ParsedCommand::Read { + cmd: "sed -n '1,10p' Cargo.toml".to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } } pub fn parse_command_impl(command: &[String]) -> Vec { @@ -1166,18 +1190,13 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option, Option } fn parse_shell_lc_commands(original: &[String]) -> Option> { - let [shell, flag, script] = original else { - return None; - }; - if flag != "-lc" || !(shell == "bash" || shell == "zsh") { - return None; - } + let (_, script) = extract_bash_command(original)?; + if let Some(tree) = try_parse_shell(script) && let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) && !all_commands.is_empty() { - let script_tokens = shlex_split(script) - .unwrap_or_else(|| vec![shell.clone(), flag.clone(), script.clone()]); + let script_tokens = shlex_split(script).unwrap_or_else(|| vec![script.to_string()]); // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we // bias toward the primary command when pipelines are present. // First, drop obvious small formatting helpers (e.g., wc/awk/etc). @@ -1186,7 +1205,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option> { let filtered_commands = drop_small_formatting_commands(all_commands); if filtered_commands.is_empty() { return Some(vec![ParsedCommand::Unknown { - cmd: script.clone(), + cmd: script.to_string(), }]); } // Build parsed commands, tracking `cd` segments to compute effective file paths. @@ -1250,7 +1269,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option> { }); if has_pipe && has_sed_n { ParsedCommand::Read { - cmd: script.clone(), + cmd: script.to_string(), name, path, } @@ -1295,7 +1314,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option> { return Some(commands); } Some(vec![ParsedCommand::Unknown { - cmd: script.clone(), + cmd: script.to_string(), }]) } diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 70ec4e84c7..6f2212b094 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::path::PathBuf; +use codex_core::bash::extract_bash_command; use dirs::home_dir; use shlex::try_join; @@ -8,19 +9,11 @@ pub(crate) fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } -fn is_login_shell_with_lc(shell: &str) -> bool { - let shell_name = std::path::Path::new(shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(shell); - matches!(shell_name, "bash" | "zsh") -} - pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { - match command { - [first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(), - _ => escape_command(command), + if let Some((_, script)) = extract_bash_command(command) { + return script.to_string(); } + escape_command(command) } /// If `path` is absolute and inside $HOME, return the part *after* the home From 6951872776bf417c40c74a92129ee45d8c9a08da Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 10 Nov 2025 18:44:47 -0800 Subject: [PATCH 112/122] [hygiene][app-server] have a helper function for duplicate code in turn APIs (#6488) turn_start and turn_interrupt have some logic that can be shared. have a helper function for it. --- .../app-server/src/codex_message_processor.rs | 83 ++++++++----------- 1 file changed, 34 insertions(+), 49 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 12bbdf71fb..8d97f82c5f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -198,6 +198,30 @@ enum ApiVersion { } impl CodexMessageProcessor { + async fn conversation_from_thread_id( + &self, + thread_id: &str, + ) -> Result<(ConversationId, Arc), JSONRPCErrorError> { + // Resolve conversation id from v2 thread id string. + let conversation_id = + ConversationId::from_string(thread_id).map_err(|err| JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + })?; + + let conversation = self + .conversation_manager + .get_conversation(conversation_id) + .await + .map_err(|_| JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + })?; + + Ok((conversation_id, conversation)) + } pub fn new( auth_manager: Arc, conversation_manager: Arc, @@ -2145,34 +2169,14 @@ impl CodexMessageProcessor { } async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) { - // Resolve conversation id from v2 thread id string. - let conversation_id = match ConversationId::from_string(¶ms.thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; + let (_, conversation) = match self.conversation_from_thread_id(¶ms.thread_id).await { + Ok(v) => v, + Err(error) => { self.outgoing.send_error(request_id, error).await; return; } }; - let Ok(conversation) = self - .conversation_manager - .get_conversation(conversation_id) - .await - else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - }; - // Keep a copy of v2 inputs for the notification payload. let v2_inputs_for_notif = params.input.clone(); @@ -2246,33 +2250,14 @@ impl CodexMessageProcessor { async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) { let TurnInterruptParams { thread_id, .. } = params; - // Resolve conversation id from v2 thread id string. - let conversation_id = match ConversationId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let Ok(conversation) = self - .conversation_manager - .get_conversation(conversation_id) - .await - else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, + let (conversation_id, conversation) = + match self.conversation_from_thread_id(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } }; - self.outgoing.send_error(request_id, error).await; - return; - }; // Record the pending interrupt so we can reply when TurnAborted arrives. { From 052b0528321232ad382c5c8be733fc70da70419c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 11 Nov 2025 09:20:46 +0000 Subject: [PATCH 113/122] Enable ghost_commit feature by default (#6041) ## Summary - enable the ghost_commit feature flag by default ## Testing - just fmt ------ https://chatgpt.com/codex/tasks/task_i_6904ce2d0370832dbb3c2c09a90fb188 --- codex-rs/core/src/features.rs | 2 +- codex-rs/core/tests/suite/otel.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index e34128f362..9efff1da44 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -284,7 +284,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::GhostCommit, key: "ghost_commit", stage: Stage::Experimental, - default_enabled: false, + default_enabled: true, }, FeatureSpec { id: Feature::WindowsSandbox, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 92dd43fb55..cb21451f43 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,3 +1,4 @@ +use codex_core::features::Feature; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -101,6 +102,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -141,6 +143,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -193,6 +196,7 @@ async fn process_sse_failed_event_records_response_error_message() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -243,6 +247,7 @@ async fn process_sse_failed_event_logs_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -288,6 +293,7 @@ async fn process_sse_failed_event_logs_missing_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -333,6 +339,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -438,6 +445,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -497,6 +505,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -566,6 +575,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) @@ -619,6 +629,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { + config.features.disable(Feature::GhostCommit); config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); }) From ad279eacdc89c4a7a0fc455d888bdcb8e63af5d6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 11 Nov 2025 13:37:06 +0000 Subject: [PATCH 114/122] nit: logs to trace (#6503) --- codex-rs/core/src/tools/parallel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index baa5321da8..5388ab9dac 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -65,9 +65,9 @@ impl ToolCallRuntime { Ok(Self::aborted_response(&call, secs)) }, res = async { - tracing::info!("waiting for tool gate"); + tracing::trace!("waiting for tool gate"); readiness.wait_ready().await; - tracing::info!("tool gate released"); + tracing::trace!("tool gate released"); let _guard = if supports_parallel { Either::Left(lock.read().await) } else { From 807e2c27f0a9f2e85c50e7e6df5533f0d9b853c7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 11 Nov 2025 08:19:35 -0800 Subject: [PATCH 115/122] Add unified exec escalation handling and tests (#6492) Similar implementation to the shell tool --- codex-rs/Cargo.lock | 10 ++-- codex-rs/core/src/codex.rs | 43 +++++++++++++++ .../core/src/tools/handlers/unified_exec.rs | 44 ++++++++++++--- .../core/src/tools/runtimes/unified_exec.rs | 47 +++++++++++++--- codex-rs/core/src/tools/spec.rs | 18 +++++++ codex-rs/core/src/unified_exec/mod.rs | 4 ++ .../core/src/unified_exec/session_manager.rs | 12 ++++- codex-rs/core/tests/suite/approvals.rs | 53 +++++++++++++++++-- 8 files changed, 208 insertions(+), 23 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cf5f961a62..8794f54af0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4450,7 +4450,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64", "indexmap 2.12.0", - "quick-xml", + "quick-xml 0.38.0", "serde", "time", ] @@ -7093,7 +7093,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "rustix 1.0.8", "wayland-backend", "wayland-scanner", @@ -7105,7 +7105,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7117,7 +7117,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7726,7 +7726,7 @@ dependencies = [ "os_pipe", "rustix 0.38.44", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tree_magic_mini", "wayland-backend", "wayland-client", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d28f671f1b..405e6ca3da 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2323,6 +2323,7 @@ mod tests { use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::ShellHandler; + use crate::tools::handlers::UnifiedExecHandler; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AuthMode; @@ -3062,6 +3063,48 @@ mod tests { assert!(exec_output.output.contains("hi")); } + #[tokio::test] + async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { + use crate::protocol::AskForApproval; + use crate::turn_diff_tracker::TurnDiffTracker; + + let (session, mut turn_context_raw) = make_session_and_context(); + turn_context_raw.approval_policy = AskForApproval::OnFailure; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context_raw); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + + let handler = UnifiedExecHandler; + let resp = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&tracker), + call_id: "exec-call".to_string(), + tool_name: "exec_command".to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "cmd": "echo hi", + "with_escalated_permissions": true, + "justification": "need unsandboxed execution", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = resp else { + panic!("expected error result"); + }; + + let expected = format!( + "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", + policy = turn_context.approval_policy + ); + + pretty_assertions::assert_eq!(output, expected); + } + #[test] fn mcp_init_error_display_prompts_for_github_pat() { let server_name = "github"; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 6542c333cc..6673cf9ba8 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -36,6 +36,10 @@ struct ExecCommandArgs { yield_time_ms: Option, #[serde(default)] max_output_tokens: Option, + #[serde(default)] + with_escalated_permissions: Option, + #[serde(default)] + justification: Option, } #[derive(Debug, Deserialize)] @@ -100,8 +104,30 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse exec_command arguments: {err:?}" )) })?; - let workdir = args - .workdir + let ExecCommandArgs { + cmd, + workdir, + shell, + login, + yield_time_ms, + max_output_tokens, + with_escalated_permissions, + justification, + } = args; + + if with_escalated_permissions.unwrap_or(false) + && !matches!( + context.turn.approval_policy, + codex_protocol::protocol::AskForApproval::OnRequest + ) + { + return Err(FunctionCallError::RespondToModel(format!( + "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}", + policy = context.turn.approval_policy + ))); + } + + let workdir = workdir .as_deref() .filter(|value| !value.is_empty()) .map(PathBuf::from); @@ -113,18 +139,20 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); - let emitter = ToolEmitter::unified_exec(args.cmd.clone(), cwd.clone(), true); + let emitter = ToolEmitter::unified_exec(cmd.clone(), cwd.clone(), true); emitter.emit(event_ctx, ToolEventStage::Begin).await; manager .exec_command( ExecCommandRequest { - command: &args.cmd, - shell: &args.shell, - login: args.login, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, + command: &cmd, + shell: &shell, + login, + yield_time_ms, + max_output_tokens, workdir, + with_escalated_permissions, + justification, }, &context, ) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 47157587bb..cddac1924e 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -34,6 +34,8 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, + pub with_escalated_permissions: Option, + pub justification: Option, } impl ProvidesSandboxRetryData for UnifiedExecRequest { @@ -49,6 +51,7 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathBuf, + pub escalated: bool, } pub struct UnifiedExecRuntime<'a> { @@ -56,8 +59,20 @@ pub struct UnifiedExecRuntime<'a> { } impl UnifiedExecRequest { - pub fn new(command: Vec, cwd: PathBuf, env: HashMap) -> Self { - Self { command, cwd, env } + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + with_escalated_permissions: Option, + justification: Option, + ) -> Self { + Self { + command, + cwd, + env, + with_escalated_permissions, + justification, + } } } @@ -84,6 +99,7 @@ impl Approvable for UnifiedExecRuntime<'_> { UnifiedExecApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), + escalated: req.with_escalated_permissions.unwrap_or(false), } } @@ -98,7 +114,10 @@ impl Approvable for UnifiedExecRuntime<'_> { let call_id = ctx.call_id.to_string(); let command = req.command.clone(); let cwd = req.cwd.clone(); - let reason = ctx.retry_reason.clone(); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { @@ -116,7 +135,16 @@ impl Approvable for UnifiedExecRuntime<'_> { policy: AskForApproval, sandbox_policy: &SandboxPolicy, ) -> bool { - requires_initial_appoval(policy, sandbox_policy, &req.command, false) + requires_initial_appoval( + policy, + sandbox_policy, + &req.command, + req.with_escalated_permissions.unwrap_or(false), + ) + } + + fn wants_escalated_first_attempt(&self, req: &UnifiedExecRequest) -> bool { + req.with_escalated_permissions.unwrap_or(false) } } @@ -127,8 +155,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt attempt: &SandboxAttempt<'_>, _ctx: &ToolCtx<'_>, ) -> Result { - let spec = build_command_spec(&req.command, &req.cwd, &req.env, None, None, None) - .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let spec = build_command_spec( + &req.command, + &req.cwd, + &req.env, + None, + req.with_escalated_permissions, + req.justification.clone(), + ) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; let exec_env = attempt .env_for(&spec) .map_err(|err| ToolError::Codex(err.into()))?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index cb1aeafdb2..471f42c086 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -177,6 +177,24 @@ fn create_exec_command_tool() -> ToolSpec { ), }, ); + properties.insert( + "with_escalated_permissions".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions" + .to_string(), + ), + }, + ); + properties.insert( + "justification".to_string(), + JsonSchema::String { + description: Some( + "Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command." + .to_string(), + ), + }, + ); ToolSpec::Function(ResponsesApiTool { name: "exec_command".to_string(), diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 16fbc4c7f0..b996ce79e3 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -71,6 +71,8 @@ pub(crate) struct ExecCommandRequest<'a> { pub yield_time_ms: Option, pub max_output_tokens: Option, pub workdir: Option, + pub with_escalated_permissions: Option, + pub justification: Option, } #[derive(Debug)] @@ -201,6 +203,8 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, + with_escalated_permissions: None, + justification: None, }, &context, ) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 55a5f589a3..c9763fbc49 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -51,7 +51,13 @@ impl UnifiedExecSessionManager { ]; let session = self - .open_session_with_sandbox(command, cwd.clone(), context) + .open_session_with_sandbox( + command, + cwd.clone(), + request.with_escalated_permissions, + request.justification, + context, + ) .await?; let max_tokens = resolve_max_tokens(request.max_output_tokens); @@ -317,6 +323,8 @@ impl UnifiedExecSessionManager { &self, command: Vec, cwd: PathBuf, + with_escalated_permissions: Option, + justification: Option, context: &UnifiedExecContext, ) -> Result { let mut orchestrator = ToolOrchestrator::new(); @@ -325,6 +333,8 @@ impl UnifiedExecSessionManager { command, cwd, create_env(&context.turn.shell_environment_policy), + with_escalated_permissions, + justification, ); let tool_ctx = ToolCtx { session: context.session.as_ref(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 68407367ec..8316d4de59 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -75,6 +75,7 @@ enum ActionKind { }, RunUnifiedExecCommand { command: &'static str, + justification: Option<&'static str>, }, ApplyPatchFunction { target: TargetPath, @@ -86,6 +87,9 @@ enum ActionKind { }, } +const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str = + "Requires escalated permissions to bypass the sandbox in tests."; + impl ActionKind { async fn prepare( &self, @@ -139,8 +143,17 @@ impl ActionKind { let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; Ok((event, Some(command))) } - ActionKind::RunUnifiedExecCommand { command } => { - let event = exec_command_event(call_id, command, Some(1000))?; + ActionKind::RunUnifiedExecCommand { + command, + justification, + } => { + let event = exec_command_event( + call_id, + command, + Some(1000), + with_escalated_permissions, + *justification, + )?; Ok(( event, Some(vec![ @@ -199,13 +212,24 @@ fn shell_event( Ok(ev_function_call(call_id, "shell", &args_str)) } -fn exec_command_event(call_id: &str, cmd: &str, yield_time_ms: Option) -> Result { +fn exec_command_event( + call_id: &str, + cmd: &str, + yield_time_ms: Option, + with_escalated_permissions: bool, + justification: Option<&str>, +) -> Result { let mut args = json!({ "cmd": cmd.to_string(), }); if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } + if with_escalated_permissions { + args["with_escalated_permissions"] = json!(true); + let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); + args["justification"] = json!(reason); + } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "exec_command", &args_str)) } @@ -1109,6 +1133,7 @@ fn scenarios() -> Vec { sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "echo \"hello unified exec\"", + justification: None, }, with_escalated_permissions: false, features: vec![Feature::UnifiedExec], @@ -1118,12 +1143,34 @@ fn scenarios() -> Vec { stdout_contains: "hello unified exec", }, }, + #[cfg(not(all(target_os = "linux", target_arch = "aarch64")))] + // Linux sandbox arg0 test workaround doesn't work on ARM + ScenarioSpec { + name: "unified exec on request escalated requires approval", + approval_policy: OnRequest, + sandbox_policy: SandboxPolicy::ReadOnly, + action: ActionKind::RunUnifiedExecCommand { + command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", + justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), + }, + with_escalated_permissions: true, + features: vec![Feature::UnifiedExec], + model_override: None, + outcome: Outcome::ExecApproval { + decision: ReviewDecision::Approved, + expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), + }, + expectation: Expectation::CommandSuccess { + stdout_contains: "escalated unified exec", + }, + }, ScenarioSpec { name: "unified exec on request requires approval unless trusted", approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "git reset --hard", + justification: None, }, with_escalated_permissions: false, features: vec![Feature::UnifiedExec], From e357fc723d144cbf8a79cc3f5e59b396ef2791bd Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Tue, 11 Nov 2025 14:43:24 -0800 Subject: [PATCH 116/122] [app-server] add item started/completed events for turn items (#6517) This one should be quite straightforward, as it's just a translation of TurnItem events we already emit to ThreadItem that app-server exposes to customers. To test, cp my change to owen/app_server_test_client and do the following: ``` cargo build -p codex-cli RUST_LOG=codex_app_server=info CODEX_BIN=target/debug/codex cargo run -p codex-app-server-test-client -- send-message-v2 "hello" ``` example event before (still kept there for backward compatibility): ``` { < "method": "codex/event/item_completed", < "params": { < "conversationId": "019a74cc-fad9-7ab3-83a3-f42827b7b074", < "id": "0", < "msg": { < "item": { < "Reasoning": { < "id": "rs_03d183492e07e20a016913a936eb8c81a1a7671a103fee8afc", < "raw_content": [], < "summary_text": [ < "Hey! What would you like to work on? I can explore the repo, run specific tests, or implement a change. Let's keep it short and straightforward. There's no need for a lengthy introduction or elaborate planning, just a friendly greeting and an open offer to help. I want to make sure the user feels welcomed and understood right from the start. It's all about keeping the tone friendly and concise!" < ] < } < }, < "thread_id": "019a74cc-fad9-7ab3-83a3-f42827b7b074", < "turn_id": "0", < "type": "item_completed" < } < } < } ``` after (v2): ``` < { < "method": "item/completed", < "params": { < "item": { < "id": "rs_03d183492e07e20a016913a936eb8c81a1a7671a103fee8afc", < "text": "Hey! What would you like to work on? I can explore the repo, run specific tests, or implement a change. Let's keep it short and straightforward. There's no need for a lengthy introduction or elaborate planning, just a friendly greeting and an open offer to help. I want to make sure the user feels welcomed and understood right from the start. It's all about keeping the tone friendly and concise!", < "type": "reasoning" < } < } < } ``` --- .../app-server-protocol/src/protocol/v2.rs | 146 ++++++++++++++++++ .../app-server/src/codex_message_processor.rs | 16 ++ 2 files changed, 162 insertions(+) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2afdb4575a..88f0f78323 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -6,6 +6,8 @@ use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -457,6 +459,17 @@ impl UserInput { } } +impl From for UserInput { + fn from(value: CoreUserInput) -> Self { + match value { + CoreUserInput::Text { text } => UserInput::Text { text }, + CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, + CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + _ => unreachable!("unsupported user input variant"), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -514,6 +527,42 @@ pub enum ThreadItem { }, } +impl From for ThreadItem { + fn from(value: CoreTurnItem) -> Self { + match value { + CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + id: user.id, + content: user.content.into_iter().map(UserInput::from).collect(), + }, + CoreTurnItem::AgentMessage(agent) => { + let text = agent + .content + .into_iter() + .map(|entry| match entry { + CoreAgentMessageContent::Text { text } => text, + }) + .collect::(); + ThreadItem::AgentMessage { id: agent.id, text } + } + CoreTurnItem::Reasoning(reasoning) => { + let text = if !reasoning.summary_text.is_empty() { + reasoning.summary_text.join("\n") + } else { + reasoning.raw_content.join("\n") + }; + ThreadItem::Reasoning { + id: reasoning.id, + text, + } + } + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + id: search.id, + query: search.query, + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -708,3 +757,100 @@ pub struct AccountLoginCompletedNotification { pub success: bool, pub error: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::items::AgentMessageContent; + use codex_protocol::items::AgentMessageItem; + use codex_protocol::items::ReasoningItem; + use codex_protocol::items::TurnItem; + use codex_protocol::items::UserMessageItem; + use codex_protocol::items::WebSearchItem; + use codex_protocol::user_input::UserInput as CoreUserInput; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn core_turn_item_into_thread_item_converts_supported_variants() { + let user_item = TurnItem::UserMessage(UserMessageItem { + id: "user-1".to_string(), + content: vec![ + CoreUserInput::Text { + text: "hello".to_string(), + }, + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }, + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + ], + }); + + assert_eq!( + ThreadItem::from(user_item), + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![ + UserInput::Text { + text: "hello".to_string(), + }, + UserInput::Image { + url: "https://example.com/image.png".to_string(), + }, + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + ], + } + ); + + let agent_item = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-1".to_string(), + content: vec![ + AgentMessageContent::Text { + text: "Hello ".to_string(), + }, + AgentMessageContent::Text { + text: "world".to_string(), + }, + ], + }); + + assert_eq!( + ThreadItem::from(agent_item), + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "Hello world".to_string(), + } + ); + + let reasoning_item = TurnItem::Reasoning(ReasoningItem { + id: "reasoning-1".to_string(), + summary_text: vec!["line one".to_string(), "line two".to_string()], + raw_content: vec![], + }); + + assert_eq!( + ThreadItem::from(reasoning_item), + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + text: "line one\nline two".to_string(), + } + ); + + let search_item = TurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + }); + + assert_eq!( + ThreadItem::from(search_item), + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "docs".to_string(), + } + ); + } +} diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8d97f82c5f..e465117dad 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -46,6 +46,8 @@ use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::InputItem as WireInputItem; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::InterruptConversationResponse; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; @@ -2609,6 +2611,20 @@ async fn apply_bespoke_event_handling( .await; } } + EventMsg::ItemStarted(item_started_event) => { + let item: ThreadItem = item_started_event.item.clone().into(); + let notification = ItemStartedNotification { item }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + EventMsg::ItemCompleted(item_completed_event) => { + let item: ThreadItem = item_completed_event.item.clone().into(); + let notification = ItemCompletedNotification { item }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; + } // If this is a TurnAborted, reply to any pending interrupt requests. EventMsg::TurnAborted(turn_aborted_event) => { let pending = { From eb1c651c00955d31e4aafcda73a89c78c4c9e871 Mon Sep 17 00:00:00 2001 From: Lionel Cheng <60159831+lionelchg@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:59:20 +0100 Subject: [PATCH 117/122] Update full-auto description with on-request (#6523) This PR fixes #6522 by correcting the comment for `full-auto` in both `codex-rs/exec/src/cli.rs` and `codex-rs/tui/src/cli.rs` from `-a on-failure` to `-a on-request` to make it coherent with `codex-rs/tui/src/lib.rs:97-105`: ```rust pub async fn run_main( mut cli: Cli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), Some(AskForApproval::OnRequest), ) ``` Running `just codex --help` or `just codex exec --help` should now yield the correct description of `full-auto` CLI argument. Signed-off-by: lionelchg --- codex-rs/exec/src/cli.rs | 2 +- codex-rs/tui/src/cli.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index f56d07dc32..065f4826a0 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -30,7 +30,7 @@ pub struct Cli { #[arg(long = "profile", short = 'p')] pub config_profile: Option, - /// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write). + /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index d86040b5c9..5a7e409932 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -51,7 +51,7 @@ pub struct Cli { #[arg(long = "ask-for-approval", short = 'a')] pub approval_policy: Option, - /// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write). + /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, From 424bfecd0b2c9d47a279052a069fe55c8bdb1ee3 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 11 Nov 2025 18:30:01 -0800 Subject: [PATCH 118/122] Re-add prettier log-level=warn to generate-ts (#6528) I added it in https://github.com/openai/codex/pull/6342 but it was removed in https://github.com/openai/codex/pull/5063/files#diff-e2aa6dad1e886b7765158a27aefd1be5de99baa71b44f6bc5ce3fe462b9ae5d3R135 as a result of a bad diamond merge --- codex-rs/app-server-protocol/src/export.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 4db011f721..a1408d61d5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -92,6 +92,8 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { { let status = Command::new(prettier_bin) .arg("--write") + .arg("--log-level") + .arg("warn") .args(ts_files.iter().map(|p| p.as_os_str())) .status() .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; From 530db0ad73a41ad97b647f4bafa998e57a91f039 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 12 Nov 2025 11:13:37 +0000 Subject: [PATCH 119/122] feat: warning switch model on resume (#6507) Screenshot 2025-11-11 at 14 01 41 --- codex-rs/core/src/codex.rs | 29 +++++++++ codex-rs/core/tests/suite/resume_warning.rs | 70 +++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 codex-rs/core/tests/suite/resume_warning.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 405e6ca3da..c3573a99ae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -97,6 +97,7 @@ use crate::protocol::Submission; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TurnDiffEvent; +use crate::protocol::WarningEvent; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::shell; @@ -674,6 +675,34 @@ impl Session { let rollout_items = conversation_history.get_rollout_items(); let persist = matches!(conversation_history, InitialHistory::Forked(_)); + // If resuming, warn when the last recorded model differs from the current one. + if let InitialHistory::Resumed(_) = conversation_history + && let Some(prev) = rollout_items.iter().rev().find_map(|it| { + if let RolloutItem::TurnContext(ctx) = it { + Some(ctx.model.as_str()) + } else { + None + } + }) + { + let curr = turn_context.client.get_model(); + if prev != curr { + warn!( + "resuming session with different model: previous={prev}, current={curr}" + ); + self.send_event( + &turn_context, + EventMsg::Warning(WarningEvent { + message: format!( + "This session was recorded with model `{prev}` but is resuming with `{curr}`. \ + Consider switching back to `{prev}` as it may affect Codex performance." + ), + }), + ) + .await; + } + } + // Always add response items to conversation history let reconstructed_history = self.reconstruct_history_from_rollout(&turn_context, &rollout_items); diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs new file mode 100644 index 0000000000..c8376e4109 --- /dev/null +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -0,0 +1,70 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::NewConversation; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InitialHistory; +use codex_core::protocol::ResumedHistory; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::TurnContextItem; +use codex_core::protocol::WarningEvent; +use codex_protocol::ConversationId; +use core::time::Duration; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; + +fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { + let turn_ctx = TurnContextItem { + cwd: config.cwd.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + model: previous_model.to_string(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + }; + + InitialHistory::Resumed(ResumedHistory { + conversation_id: ConversationId::default(), + history: vec![RolloutItem::TurnContext(turn_ctx)], + rollout_path: rollout_path.to_path_buf(), + }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_warning_when_resumed_model_differs() { + // Arrange a config with a current model and a prior rollout recorded under a different model. + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home); + config.model = "current-model".to_string(); + // Ensure cwd is absolute (the helper sets it to the temp dir already). + assert!(config.cwd.is_absolute()); + + let rollout_path = home.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "").expect("create rollout placeholder"); + + let initial_history = resume_history(&config, "previous-model", &rollout_path); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + // Act: resume the conversation. + let NewConversation { conversation, .. } = conversation_manager + .resume_conversation_with_history(config, initial_history, auth_manager) + .await + .expect("resume conversation"); + + // Assert: a Warning event is emitted describing the model mismatch. + let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!(message.contains("previous-model")); + assert!(message.contains("current-model")); + + // Drain the TaskComplete/Shutdown window to avoid leaking tasks between tests. + // The warning is emitted during initialization, so a short sleep is sufficient. + tokio::time::sleep(Duration::from_millis(50)).await; +} From 29364f3a9b116c5741f5bb79733f0b4040d82a61 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 12 Nov 2025 08:18:57 -0800 Subject: [PATCH 120/122] feat: shell_command tool (#6510) This adds support for a new variant of the shell tool behind a flag. To test, run `codex` with `--enable shell_command_tool`, which will register the tool with Codex under the name `shell_command` that accepts the following shape: ```python { command: str workdir: str | None, timeout_ms: int | None, with_escalated_permissions: bool | None, justification: str | None, } ``` This is comparable to the existing tool registered under `shell`/`container.exec`. The primary difference is that it accepts `command` as a `str` instead of a `str[]`. The `shell_command` tool executes by running `execvp(["bash", "-lc", command])`, though the exact arguments to `execvp(3)` depend on the user's default shell. The hypothesis is that this will simplify things for the model. For example, on Windows, instead of generating: ```json {"command": ["pwsh.exe", "-NoLogo", "-Command", "ls -Name"]} ``` The model could simply generate: ```json {"command": "ls -Name"} ``` As part of this change, I extracted some logic out of `user_shell.rs` as `Shell::derive_exec_args()` so that it can be reused in `codex-rs/core/src/tools/handlers/shell.rs`. Note the original code generated exec arg lists like: ```javascript ["bash", "-lc", command] ["zsh", "-lc", command] ["pwsh.exe", "-NoProfile", "-Command", command] ``` Using `-l` for Bash and Zsh, but then specifying `-NoProfile` for PowerShell seemed inconsistent to me, so I changed this in the new implementation while also adding a `use_login_shell: bool` option to make this explicit. If we decide to add a `login: bool` to `ShellCommandToolCallParams` like we have for unified exec: https://github.com/openai/codex/blob/807e2c27f0a9f2e85c50e7e6df5533f0d9b853c7/codex-rs/core/src/tools/handlers/unified_exec.rs#L33-L34 Then this should make it straightforward to support. --- codex-rs/core/src/bash.rs | 2 +- codex-rs/core/src/features.rs | 9 ++ codex-rs/core/src/shell.rs | 33 +++++-- codex-rs/core/src/tasks/user_shell.rs | 25 +---- codex-rs/core/src/tools/handlers/mod.rs | 1 + codex-rs/core/src/tools/handlers/shell.rs | 114 ++++++++++++++++++++++ codex-rs/core/src/tools/spec.rs | 89 +++++++++++++++++ codex-rs/protocol/src/models.rs | 18 +++- 8 files changed, 262 insertions(+), 29 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index e85ce76a5e..edeebaba37 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -104,7 +104,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { let [shell, flag, script] = command else { return None; }; - if flag != "-lc" || !is_well_known_sh_shell(shell) { + if !matches!(flag.as_str(), "-lc" | "-c") || !is_well_known_sh_shell(shell) { return None; } Some((shell, script)) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 9efff1da44..2a49eb1ed7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -29,6 +29,9 @@ pub enum Stage { pub enum Feature { /// Use the single unified PTY-backed exec tool. UnifiedExec, + /// Use the shell command tool that takes `command` as a single string of + /// shell instead of an array of args passed to `execvp(3)`. + ShellCommandTool, /// Enable experimental RMCP features such as OAuth login. RmcpClient, /// Include the freeform apply_patch tool. @@ -250,6 +253,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellCommandTool, + key: "shell_command_tool", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::RmcpClient, key: "rmcp_client", diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 197c00bad2..90f6c6b97a 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -31,16 +31,37 @@ pub enum Shell { impl Shell { pub fn name(&self) -> Option { match self { - Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()), - Shell::Bash(bash) => std::path::Path::new(&bash.shell_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()), + Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => { + std::path::Path::new(shell_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + } Shell::PowerShell(ps) => Some(ps.exe.clone()), Shell::Unknown => None, } } + + /// Takes a string of shell and returns the full list of command args to + /// use with `exec()` to run the shell command. + pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec { + match self { + Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => { + let arg = if use_login_shell { "-lc" } else { "-c" }; + vec![shell_path.clone(), arg.to_string(), command.to_string()] + } + Shell::PowerShell(ps) => { + let mut args = vec![ps.exe.clone(), "-NoLogo".to_string()]; + if !use_login_shell { + args.push("-NoProfile".to_string()); + } + + args.push("-Command".to_string()); + args.push(command.to_string()); + args + } + Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]), + } + } } #[cfg(unix)] diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 28a84f23f3..e894a08444 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -63,27 +63,10 @@ impl SessionTask for UserShellCommandTask { // Execute the user's script under their default shell when known; this // allows commands that use shell features (pipes, &&, redirects, etc.). // We do not source rc files or otherwise reformat the script. - let shell_invocation = match session.user_shell() { - crate::shell::Shell::Zsh(zsh) => vec![ - zsh.shell_path.clone(), - "-lc".to_string(), - self.command.clone(), - ], - crate::shell::Shell::Bash(bash) => vec![ - bash.shell_path.clone(), - "-lc".to_string(), - self.command.clone(), - ], - crate::shell::Shell::PowerShell(ps) => vec![ - ps.exe.clone(), - "-NoProfile".to_string(), - "-Command".to_string(), - self.command.clone(), - ], - crate::shell::Shell::Unknown => { - shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()]) - } - }; + let use_login_shell = true; + let shell_invocation = session + .user_shell() + .derive_exec_args(&self.command, use_login_shell); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 187b444414..dcf848e376 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -19,6 +19,7 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; +pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use unified_exec::UnifiedExecHandler; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b97242a9a3..81915fc129 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use codex_protocol::models::ShellCommandToolCallParams; use codex_protocol::models::ShellToolCallParams; use std::sync::Arc; @@ -25,6 +26,8 @@ use crate::tools::sandboxing::ToolCtx; pub struct ShellHandler; +pub struct ShellCommandHandler; + impl ShellHandler { fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams { ExecParams { @@ -39,6 +42,28 @@ impl ShellHandler { } } +impl ShellCommandHandler { + fn to_exec_params( + params: ShellCommandToolCallParams, + session: &crate::codex::Session, + turn_context: &TurnContext, + ) -> ExecParams { + let shell = session.user_shell(); + let use_login_shell = true; + let command = shell.derive_exec_args(¶ms.command, use_login_shell); + + ExecParams { + command, + cwd: turn_context.resolve_path(params.workdir.clone()), + timeout_ms: params.timeout_ms, + env: create_env(&turn_context.shell_environment_policy), + with_escalated_permissions: params.with_escalated_permissions, + justification: params.justification, + arg0: None, + } + } +} + #[async_trait] impl ToolHandler for ShellHandler { fn kind(&self) -> ToolKind { @@ -102,6 +127,49 @@ impl ToolHandler for ShellHandler { } } +#[async_trait] +impl ToolHandler for ShellCommandHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + tool_name, + payload, + } = invocation; + + let ToolPayload::Function { arguments } = payload else { + return Err(FunctionCallError::RespondToModel(format!( + "unsupported payload for shell_command handler: {tool_name}" + ))); + }; + + let params: ShellCommandToolCallParams = serde_json::from_str(&arguments).map_err(|e| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}")) + })?; + let exec_params = Self::to_exec_params(params, session.as_ref(), turn.as_ref()); + ShellHandler::run_exec_like( + tool_name.as_str(), + exec_params, + session, + turn, + tracker, + call_id, + false, + ) + .await + } +} + impl ShellHandler { async fn run_exec_like( tool_name: &str, @@ -240,3 +308,49 @@ impl ShellHandler { }) } } + +#[cfg(test)] +mod tests { + use crate::is_safe_command::is_known_safe_command; + use crate::shell::BashShell; + use crate::shell::Shell; + use crate::shell::ZshShell; + + /// The logic for is_known_safe_command() has heuristics for known shells, + /// so we must ensure the commands generated by [ShellCommandHandler] can be + /// recognized as safe if the `command` is safe. + #[test] + fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() { + let bash_shell = Shell::Bash(BashShell { + shell_path: "/bin/bash".to_string(), + bashrc_path: "/home/user/.bashrc".to_string(), + }); + assert_safe(&bash_shell, "ls -la"); + + let zsh_shell = Shell::Zsh(ZshShell { + shell_path: "/bin/zsh".to_string(), + zshrc_path: "/home/user/.zshrc".to_string(), + }); + assert_safe(&zsh_shell, "ls -la"); + + #[cfg(target_os = "windows")] + { + use crate::shell::PowerShellConfig; + + let powershell = Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: None, + }); + assert_safe(&powershell, "ls -Name"); + } + } + + fn assert_safe(shell: &Shell, command: &str) { + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ true) + )); + assert!(is_known_safe_command( + &shell.derive_exec_args(command, /* use_login_shell */ false) + )); + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 471f42c086..82ef57d658 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -20,6 +20,8 @@ pub enum ConfigShellToolType { Default, Local, UnifiedExec, + /// Takes a command as a single string to be run in the user's default shell. + ShellCommand, } #[derive(Debug, Clone)] @@ -48,6 +50,8 @@ impl ToolsConfig { let shell_type = if features.enabled(Feature::UnifiedExec) { ConfigShellToolType::UnifiedExec + } else if features.enabled(Feature::ShellCommandTool) { + ConfigShellToolType::ShellCommand } else { model_family.shell_type.clone() }; @@ -302,6 +306,53 @@ fn create_shell_tool() -> ToolSpec { }) } +fn create_shell_command_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "command".to_string(), + JsonSchema::String { + description: Some( + "The shell script to execute in the user's default shell".to_string(), + ), + }, + ); + properties.insert( + "workdir".to_string(), + JsonSchema::String { + description: Some("The working directory to execute the command in".to_string()), + }, + ); + properties.insert( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some("The timeout for the command in milliseconds".to_string()), + }, + ); + properties.insert( + "with_escalated_permissions".to_string(), + JsonSchema::Boolean { + description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), + }, + ); + properties.insert( + "justification".to_string(), + JsonSchema::String { + description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "shell_command".to_string(), + description: "Runs a shell command string and returns its output.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["command".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_view_image_tool() -> ToolSpec { // Support only local filesystem path. let mut properties = BTreeMap::new(); @@ -891,6 +942,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; + use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::UnifiedExecHandler; @@ -906,6 +958,7 @@ pub(crate) fn build_specs( let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); + let shell_command_handler = Arc::new(ShellCommandHandler); match &config.shell_type { ConfigShellToolType::Default => { @@ -920,12 +973,16 @@ pub(crate) fn build_specs( builder.register_handler("exec_command", unified_exec_handler.clone()); builder.register_handler("write_stdin", unified_exec_handler); } + ConfigShellToolType::ShellCommand => { + builder.push_spec(create_shell_command_tool()); + } } // Always register shell aliases so older prompts remain compatible. builder.register_handler("shell", shell_handler.clone()); builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); + builder.register_handler("shell_command", shell_command_handler); builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); @@ -1061,6 +1118,7 @@ mod tests { ConfigShellToolType::Default => Some("shell"), ConfigShellToolType::Local => Some("local_shell"), ConfigShellToolType::UnifiedExec => None, + ConfigShellToolType::ShellCommand => Some("shell_command"), } } @@ -1293,6 +1351,22 @@ mod tests { assert_contains_tool_names(&tools, &subset); } + #[test] + fn test_build_specs_shell_command_present() { + assert_model_tools( + "codex-mini-latest", + Features::with_defaults().enable(Feature::ShellCommandTool), + &[ + "shell_command", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ); + } + #[test] #[ignore] fn test_parallel_support_flags() { @@ -1748,6 +1822,21 @@ mod tests { assert_eq!(description, expected); } + #[test] + fn test_shell_command_tool() { + let tool = super::create_shell_command_tool(); + let ToolSpec::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell_command"); + + let expected = "Runs a shell command string and returns its output."; + assert_eq!(description, expected); + } + #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { let model_family = find_family_for_model("gpt-5-codex") diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index a824ee91e4..f44d847099 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -292,7 +292,7 @@ impl From> for ResponseInputItem { } /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` -/// or shell`, the `arguments` field should deserialize to this struct. +/// or `shell`, the `arguments` field should deserialize to this struct. #[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct ShellToolCallParams { pub command: Vec, @@ -307,6 +307,22 @@ pub struct ShellToolCallParams { pub justification: Option, } +/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the +/// `arguments` field should deserialize to this struct. +#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct ShellCommandToolCallParams { + pub command: String, + pub workdir: Option, + + /// This is the maximum time in milliseconds that the command is allowed to run. + #[serde(alias = "timeout")] + pub timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub with_escalated_permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub justification: Option, +} + /// Responses API compatible content items that can be returned by a tool call. /// This is a subset of ContentItem with the types we support as function call outputs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] From c3a710ee14569b736aef23a0e733adbf1c673660 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 12 Nov 2025 08:19:16 -0800 Subject: [PATCH 121/122] chore: verify boolean values can be parsed as config overrides (#6516) This is important to ensure that this: ``` codex --enable unified_exec ``` and this: ``` codex --config features.unified_exec=true ``` are equivalent. Also that when it is passed programmatically: https://github.com/openai/codex/blob/807e2c27f0a9f2e85c50e7e6df5533f0d9b853c7/codex-rs/app-server-protocol/src/protocol/v1.rs#L55 then this should work for `config`: ```json {"features": {"shell_command_tool": true}} ``` though I believe also this: ```json {"features.shell_command_tool": true} ``` --- codex-rs/common/src/config_override.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs index 597ec11cdc..59dde92a22 100644 --- a/codex-rs/common/src/config_override.rs +++ b/codex-rs/common/src/config_override.rs @@ -151,6 +151,15 @@ mod tests { assert_eq!(v.as_integer(), Some(42)); } + #[test] + fn parses_bool() { + let true_literal = parse_toml_value("true").expect("parse"); + assert_eq!(true_literal.as_bool(), Some(true)); + + let false_literal = parse_toml_value("false").expect("parse"); + assert_eq!(false_literal.as_bool(), Some(false)); + } + #[test] fn fails_on_unquoted_string() { assert!(parse_toml_value("hello").is_err()); From 7d9ad3effdd5194f58fe01ebefa0307e824684a6 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 12 Nov 2025 08:35:34 -0800 Subject: [PATCH 122/122] Fix otel tests (#6541) Mount responses only once, remove unneeded retries and add a final assistant messages to complete the turn. --- codex-rs/core/tests/common/responses.rs | 6 - codex-rs/core/tests/suite/otel.rs | 184 +++++++++++++++++------- 2 files changed, 136 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 1eaf9d0c99..8a62903600 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -446,12 +446,6 @@ pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock { response_mock } -pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock { - let (mock, response_mock) = base_mock(); - mock.respond_with(sse_response(body)).mount(server).await; - response_mock -} - pub async fn start_mock_server() -> MockServer { MockServer::builder() .body_print_limit(BodyPrintLimit::Limited(80_000)) diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index cb21451f43..8665d3a8ea 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -9,7 +9,6 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::ev_function_call; -use core_test_support::responses::mount_sse; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -103,8 +102,6 @@ async fn process_sse_emits_failed_event_on_parse_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -144,8 +141,6 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -193,12 +188,18 @@ async fn process_sse_failed_event_records_response_error_message() { })]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -244,12 +245,18 @@ async fn process_sse_failed_event_logs_parse_error() { })]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -294,8 +301,6 @@ async fn process_sse_failed_event_logs_missing_error() { let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -337,11 +342,18 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -430,7 +442,7 @@ async fn process_sse_emits_completed_telemetry() { async fn handle_response_item_records_tool_result_for_custom_tool_call() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_custom_tool_call( @@ -442,12 +454,18 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { ]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -494,7 +512,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { async fn handle_response_item_records_tool_result_for_function_call() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_function_call("function-call", "nonexistent", "{\"value\":1}"), @@ -503,11 +521,18 @@ async fn handle_response_item_records_tool_result_for_function_call() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -554,7 +579,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ serde_json::json!({ @@ -573,11 +598,18 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -618,7 +650,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() async fn handle_response_item_records_tool_result_for_local_shell_call() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]), @@ -627,11 +659,18 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(move |config| { config.features.disable(Feature::GhostCommit); - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -710,10 +749,23 @@ fn tool_decision_assertion<'a>( #[traced_test] async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ - ev_local_shell_call("auto_config_call", "completed", vec!["/bin/echo", "hello"]), + ev_local_shell_call( + "auto_config_call", + "completed", + vec!["/bin/echo", "local shell"], + ), + ev_completed("done"), + ]), + ) + .await; + + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), ev_completed("done"), ]), ) @@ -723,8 +775,6 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { .with_config(|config| { config.approval_policy = AskForApproval::OnRequest; config.sandbox_policy = SandboxPolicy::DangerFullAccess; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -739,7 +789,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; logs_assert(tool_decision_assertion( "auto_config_call", @@ -752,7 +802,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { #[traced_test] async fn handle_container_exec_user_approved_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), @@ -761,11 +811,18 @@ async fn handle_container_exec_user_approved_records_tool_decision() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -804,7 +861,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { async fn handle_container_exec_user_approved_for_session_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), @@ -812,12 +869,18 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() ]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -856,7 +919,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), @@ -864,12 +927,18 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { ]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -908,7 +977,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { async fn handle_container_exec_user_denies_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), @@ -917,11 +986,17 @@ async fn handle_container_exec_user_denies_records_tool_decision() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -960,7 +1035,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), @@ -968,12 +1043,18 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() ]), ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await @@ -1012,7 +1093,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() async fn handle_sandbox_error_user_denies_records_tool_decision() { let server = start_mock_server().await; - mount_sse( + mount_sse_once( &server, sse(vec![ ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), @@ -1021,11 +1102,18 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { ) .await; + mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "local shell done"), + ev_completed("done"), + ]), + ) + .await; + let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.approval_policy = AskForApproval::UnlessTrusted; - config.model_provider.request_max_retries = Some(0); - config.model_provider.stream_max_retries = Some(0); }) .build(&server) .await