From 32b45a43e2670a78a2158cad64b16a82579ffb40 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 14 May 2026 12:59:56 -0700 Subject: [PATCH 01/51] tests: isolate codex home for live cli (#22563) ## Why Some core integration-test paths were creating Codex state under ambient `~/.codex`. In environments where `HOME=/tmp`, that showed up as `/tmp/.codex`, which is host-level shared state and makes these tests environment/order sensitive. The affected paths were: - `core/tests/suite/live_cli.rs`: `run_live()` spawned the real CLI with a temp cwd, but without an isolated home, so the child resolved Codex home from ambient `HOME`. - core / exec-server integration test binaries using `configure_test_binary_dispatch(...)`: their startup ctor installs arg0 helper aliases like `apply_patch` and `codex-linux-sandbox`. Full `arg0_dispatch()` also installs aliases from ambient Codex-home resolution, so test-binary startup could create `CODEX_HOME/tmp/arg0`; with `HOME=/tmp`, that became `/tmp/.codex/tmp/arg0/...`. ## What changed - `live_cli` now gives the spawned CLI a temp `HOME` and temp `CODEX_HOME`. - arg0 alias setup now has an explicit-home form, `prepend_path_entry_for_codex_aliases_in(...)`, so test helpers can place alias state under a temp directory without relying on ambient `CODEX_HOME`. - helper re-entry behavior is preserved with `dispatch_arg0_if_needed()`, so aliases like `apply_patch` and `codex-linux-sandbox` still dispatch correctly before test alias installation. - core test support keeps the temp Codex home alive for the lifetime of the test binary, matching the alias lifetime. ## Verification Verified on `dev2` with `HOME=/tmp` that the focused core test-binary startup path no longer recreates `/tmp/.codex`. Also checked the exact `live_cli` test path under `HOME=/tmp`; on `dev2` it still hits the existing remote-only `cargo_bin("codex-rs")` resolution failure before spawning the child, but `/tmp/.codex` remains absent after the run. --- codex-rs/core/tests/suite/live_cli.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/core/tests/suite/live_cli.rs b/codex-rs/core/tests/suite/live_cli.rs index c5c26a1c8e..5e2c0415ea 100644 --- a/codex-rs/core/tests/suite/live_cli.rs +++ b/codex-rs/core/tests/suite/live_cli.rs @@ -23,6 +23,9 @@ fn run_live(prompt: &str) -> (assert_cmd::assert::Assert, TempDir) { use std::thread; let dir = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let codex_home = home.path().join(".codex"); + std::fs::create_dir_all(&codex_home).unwrap(); // Build a plain `std::process::Command` so we have full control over the underlying stdio // handles. `assert_cmd`’s own `Command` wrapper always forces stdout/stderr to be piped @@ -33,6 +36,8 @@ fn run_live(prompt: &str) -> (assert_cmd::assert::Assert, TempDir) { let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("codex-rs").unwrap()); cmd.current_dir(dir.path()); cmd.env("OPENAI_API_KEY", require_api_key()); + cmd.env("HOME", home.path()); + cmd.env("CODEX_HOME", &codex_home); // We want three things at once: // 1. live streaming of the child’s stdout/stderr while the test is running From 02a720525015f3913a7ffa5d46834d5f50868f9e Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Thu, 14 May 2026 17:11:36 -0400 Subject: [PATCH 02/51] [codex] Support multiple forced ChatGPT workspaces (#18161) ## Summary This change lets `forced_chatgpt_workspace_id` accept multiple workspace IDs instead of a single value. It keeps the existing config key name, adds backward-compatible parsing for a single string in `config.toml`, and normalizes the setting into an allowed workspace list across login enforcement, app-server config surfaces, and local ChatGPT auth helpers. ## Why Workspace-restricted deployments may need to allow more than one ChatGPT workspace without dropping the guardrail entirely. ## Server-side impact Codex's local server and app-server protocol needed changes because they previously assumed a single workspace ID. The local login flow now matches the auth backend interface by sending the allowed workspace list as a single comma-separated `allowed_workspace_id` query parameter. ## Validation This was tested with: - A single workspace config - With multi-workspace configs - With multiple workspaces in the config - The user only being a part of a subset of them All were successful. Automated coverage: - `cargo test -p codex-login` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-tui local_chatgpt_auth` - `cargo test --locked -p codex-app-server login_account_chatgpt_includes_forced_workspace_allowlist_query_param` --- .../codex_app_server_protocol.schemas.json | 24 +++- .../codex_app_server_protocol.v2.schemas.json | 24 +++- .../schema/json/v2/ConfigReadResponse.json | 24 +++- .../schema/typescript/v2/Config.ts | 3 +- .../v2/ForcedChatgptWorkspaceIds.ts | 8 ++ .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v1.rs | 3 +- .../src/protocol/v2/config.rs | 20 ++- .../request_processors/account_processor.rs | 6 +- codex-rs/app-server/tests/suite/v2/account.rs | 111 ++++++++++++---- .../app-server/tests/suite/v2/config_rpc.rs | 81 ++++++++++++ codex-rs/cli/src/login.rs | 2 +- codex-rs/config/src/config_toml.rs | 112 ++++++++++++++++- codex-rs/core/config.schema.json | 22 +++- codex-rs/core/src/config/config_tests.rs | 49 ++++++++ codex-rs/core/src/config/mod.rs | 28 +++-- codex-rs/login/src/auth/auth_tests.rs | 119 ++++++++++++++++-- codex-rs/login/src/auth/manager.rs | 39 +++--- codex-rs/login/src/server.rs | 19 +-- .../login/tests/suite/device_code_login.rs | 13 +- .../login/tests/suite/login_server_e2e.rs | 68 ++++++++-- codex-rs/tui/src/local_chatgpt_auth.rs | 14 +-- 22 files changed, 673 insertions(+), 117 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 066e1f4870..e996ca5417 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7126,9 +7126,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -8697,6 +8701,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 95ce7e4aef..7f73f9fe07 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3515,9 +3515,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -5086,6 +5090,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index cb9176e560..c21d146a4a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -235,9 +235,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -581,6 +585,20 @@ } ] }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index cc7e340ea3..50d652cea5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -10,6 +10,7 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { AnalyticsConfig } from "./AnalyticsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; @@ -19,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts new file mode 100644 index 0000000000..d0582c8f4f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Backward-compatible API shape for ChatGPT workspace login restrictions. + */ +export type ForcedChatgptWorkspaceIds = string | Array; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index ab6eaefb5a..984154ba04 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -113,6 +113,7 @@ export type { FileSystemPath } from "./FileSystemPath"; export type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; export type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; export type { FileUpdateChange } from "./FileUpdateChange"; +export type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; export type { FsChangedNotification } from "./FsChangedNotification"; export type { FsCopyParams } from "./FsCopyParams"; export type { FsCopyResponse } from "./FsCopyResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f8e9afdd12..3c45c20b8f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -22,6 +22,7 @@ use serde::Serialize; use ts_rs::TS; use crate::protocol::common::AuthMode; +use crate::protocol::v2::ForcedChatgptWorkspaceIds; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -201,7 +202,7 @@ pub struct UserSavedConfig { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_settings: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub model: Option, pub model_reasoning_effort: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 108ce9252f..ca929b02a9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -225,6 +225,24 @@ pub struct AppsConfig { pub apps: HashMap, } +/// Backward-compatible API shape for ChatGPT workspace login restrictions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + pub fn into_vec(self) -> Vec { + match self { + Self::Single(value) => vec![value], + Self::Multiple(values) => values, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -242,7 +260,7 @@ pub struct Config { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub web_search: Option, pub tools: Option, diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index f32f87ed15..17d78fb20b 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -570,11 +570,11 @@ impl AccountRequestProcessor { } } - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace + if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref() + && !expected_workspaces.contains(&chatgpt_account_id) { return Err(invalid_request(format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + "External auth must use one of workspace(s) {expected_workspaces:?}, but received {chatgpt_account_id:?}.", ))); } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 50c365d633..a15b46abd9 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -44,20 +44,30 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; +use url::Url; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -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(60); const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; +const WORKSPACE_ID_EMBEDDED: &str = "123e4567-e89b-42d3-a456-426614174010"; +const WORKSPACE_ID_INITIAL: &str = "123e4567-e89b-42d3-a456-426614174011"; +const WORKSPACE_ID_REFRESHED: &str = "123e4567-e89b-42d3-a456-426614174012"; +const WORKSPACE_ID_DEVICE: &str = "123e4567-e89b-42d3-a456-426614174013"; +const WORKSPACE_ID_STALE: &str = "123e4567-e89b-42d3-a456-426614174014"; // Helper to create a minimal config.toml for the app server #[derive(Default)] struct CreateConfigTomlParams { forced_method: Option, forced_workspace_id: Option, + forced_workspace_ids: Option>, requires_openai_auth: Option, base_url: Option, model_provider_id: Option, @@ -76,6 +86,13 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: }; let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { format!("forced_chatgpt_workspace_id = \"{ws}\"\n") + } else if let Some(workspaces) = params.forced_workspace_ids { + let workspaces = workspaces + .into_iter() + .map(|workspace_id| format!("\"{workspace_id}\"")) + .collect::>() + .join(", "); + format!("forced_chatgpt_workspace_id = [{workspaces}]\n") } else { String::new() }; @@ -248,7 +265,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -257,7 +274,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -322,7 +339,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -331,7 +348,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -441,13 +458,13 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let refreshed_access_token = encode_id_token( &ChatGptIdTokenClaims::new() .email("refreshed@example.com") .plan_type("pro") - .chatgpt_account_id("org-refreshed"), + .chatgpt_account_id(WORKSPACE_ID_REFRESHED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -456,7 +473,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token.clone(), - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -499,7 +516,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { respond_to_refresh_request( &mut mcp, &refreshed_access_token, - "org-refreshed", + WORKSPACE_ID_REFRESHED, Some("pro"), ) .await?; @@ -553,7 +570,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -562,7 +579,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -651,7 +668,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { create_config_toml( codex_home.path(), CreateConfigTomlParams { - forced_workspace_id: Some("org-expected".to_string()), + forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()), requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), ..Default::default() @@ -669,13 +686,13 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-expected"), + .chatgpt_account_id(WORKSPACE_ID_ALLOWED), )?; let refreshed_access_token = encode_id_token( &ChatGptIdTokenClaims::new() .email("refreshed@example.com") .plan_type("pro") - .chatgpt_account_id("org-other"), + .chatgpt_account_id(WORKSPACE_ID_DISALLOWED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -684,7 +701,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-expected".to_string(), + WORKSPACE_ID_ALLOWED.to_string(), Some("pro".to_string()), ) .await?; @@ -738,7 +755,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { request_id, serde_json::to_value(ChatgptAuthTokensRefreshResponse { access_token: refreshed_access_token, - chatgpt_account_id: "org-other".to_string(), + chatgpt_account_id: WORKSPACE_ID_DISALLOWED.to_string(), chatgpt_plan_type: Some("pro".to_string()), })?, ) @@ -790,7 +807,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -799,7 +816,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -853,7 +870,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { request_id, serde_json::to_value(ChatgptAuthTokensRefreshResponse { access_token: "not-a-jwt".to_string(), - chatgpt_account_id: "org-initial".to_string(), + chatgpt_account_id: WORKSPACE_ID_INITIAL.to_string(), chatgpt_plan_type: Some("pro".to_string()), })?, ) @@ -1062,7 +1079,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> &ChatGptIdTokenClaims::new() .email("device@example.com") .plan_type("pro") - .chatgpt_account_id("org-device"), + .chatgpt_account_id(WORKSPACE_ID_DEVICE), )?; mock_device_code_oauth_token(&mock_server, &id_token).await; @@ -1378,14 +1395,14 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; // Set an external auth token instead of completing the ChatGPT login flow. // This should cancel the active login attempt. let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -1428,7 +1445,7 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result create_config_toml( codex_home.path(), CreateConfigTomlParams { - forced_workspace_id: Some("ws-forced".to_string()), + forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()), ..Default::default() }, )?; @@ -1448,12 +1465,56 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result bail!("unexpected login response: {login:?}"); }; assert!( - auth_url.contains("allowed_workspace_id=ws-forced"), + auth_url.contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")), "auth URL should include forced workspace" ); 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_allowlist_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_ids: Some(vec![ + WORKSPACE_ID_ALLOWED.to_string(), + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + ]), + ..Default::default() + }, + )?; + + 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:?}"); + }; + let auth_url = Url::parse(&auth_url)?; + let allowed_workspace_ids = auth_url + .query_pairs() + .filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned())) + .collect::>(); + assert_eq!( + allowed_workspace_ids, + vec![format!( + "{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}" + )] + ); + Ok(()) +} + #[tokio::test] async fn get_account_no_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -1662,7 +1723,7 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<( codex_home.path(), ChatGptAuthFixture::new("stale-access-token") .refresh_token("stale-refresh-token") - .account_id("acct_123") + .account_id(WORKSPACE_ID_STALE) .email("user@example.com") .plan_type("pro") .last_refresh(Some(Utc::now() - ChronoDuration::days(9))), diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 2cbf476f1a..f95b9fc6dc 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ForcedChatgptWorkspaceIds; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; @@ -164,6 +165,86 @@ allowed_domains = ["example.com"] Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_accepts_legacy_forced_chatgpt_workspace_id() -> Result<()> { + const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174000"; + + let codex_home = TempDir::new()?; + write_config( + &codex_home, + &format!( + r#" +forced_chatgpt_workspace_id = "{WORKSPACE_ID}" +"# + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Single(WORKSPACE_ID.to_string())) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_accepts_forced_chatgpt_workspace_id_list() -> Result<()> { + const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; + const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; + + let codex_home = TempDir::new()?; + write_config( + &codex_home, + &format!( + r#" +forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] +"# + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + WORKSPACE_ID_A.to_string(), + WORKSPACE_ID_B.to_string(), + ])) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_nested_web_search_tool_config() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 16add7ac90..6fdc62fdb1 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -115,7 +115,7 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { pub async fn login_with_chatgpt( codex_home: PathBuf, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let opts = ServerOptions::new( diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 591b259676..055df69529 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -27,6 +27,7 @@ use crate::types::ToolSuggestConfig; use crate::types::Tui; use crate::types::UriBasedFileOpener; use crate::types::WindowsToml; +use codex_app_server_protocol::ForcedChatgptWorkspaceIds as ApiForcedChatgptWorkspaceIds; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; @@ -56,6 +57,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::de::Error as SerdeError; const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ AMAZON_BEDROCK_PROVIDER_ID, @@ -86,6 +88,55 @@ const fn default_hide_agent_reasoning() -> Option { Some(false) } +/// Backward-compatible shape for ChatGPT workspace login restrictions in config.toml. +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged)] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + pub fn into_vec(self) -> Vec { + match self { + Self::Single(value) => vec![value], + Self::Multiple(values) => values, + } + } + + pub fn into_api(self) -> ApiForcedChatgptWorkspaceIds { + match self { + Self::Single(value) => ApiForcedChatgptWorkspaceIds::Single(value), + Self::Multiple(values) => ApiForcedChatgptWorkspaceIds::Multiple(values), + } + } +} + +impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Single(String), + Multiple(Vec), + } + + match Repr::deserialize(deserializer)? { + Repr::Single(value) if value.contains(',') => Err(D::Error::custom( + "forced_chatgpt_workspace_id must be a single workspace ID string or a TOML list \ +of strings; comma-separated strings are not supported. Use \ +`forced_chatgpt_workspace_id = [\"123e4567-e89b-42d3-a456-426614174000\", \ +\"123e4567-e89b-42d3-a456-426614174001\"]` instead.", + )), + Repr::Single(value) => Ok(Self::Single(value)), + Repr::Multiple(values) => Ok(Self::Multiple(values)), + } + } +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -177,9 +228,9 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. + /// When set, restricts ChatGPT login to one or more workspace identifiers. #[serde(default)] - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. #[serde(default)] @@ -507,7 +558,9 @@ impl From for UserSavedConfig { approval_policy: config_toml.approval_policy, sandbox_mode: config_toml.sandbox_mode, sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_chatgpt_workspace_id: config_toml + .forced_chatgpt_workspace_id + .map(ForcedChatgptWorkspaceIds::into_api), forced_login_method: config_toml.forced_login_method, model: config_toml.model, model_reasoning_effort: config_toml.model_reasoning_effort, @@ -950,3 +1003,56 @@ pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { )), } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; + const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; + + #[test] + fn forced_chatgpt_workspace_id_accepts_single_string() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A}""# + )) + .expect("single workspace id should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace id should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_accepts_string_list() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]"# + )) + .expect("workspace id list should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace ids should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_rejects_comma_separated_string() { + let err = toml::from_str::(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A},{WORKSPACE_ID_B}""# + )) + .expect_err("comma-separated string should be rejected"); + + let message = err.to_string(); + assert!(message.contains("TOML list of strings")); + assert!(message.contains("comma-separated strings are not supported")); + } +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9fc95349d2..84ce98ffc5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -852,6 +852,20 @@ }, "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible shape for ChatGPT workspace login restrictions in config.toml." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -4383,9 +4397,13 @@ "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." }, "forced_chatgpt_workspace_id": { + "allOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + } + ], "default": null, - "description": "When set, restricts ChatGPT login to a specific workspace identifier.", - "type": "string" + "description": "When set, restricts ChatGPT login to one or more workspace identifiers." }, "forced_login_method": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ff28e50436..c2ef25e6cb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -2725,6 +2725,55 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() ); } +#[tokio::test] +async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction() +-> std::io::Result<()> { + let cases: Vec<(&str, &str, Option>)> = vec![ + ("unset", "", None), + ("empty string", r#"forced_chatgpt_workspace_id = """#, None), + ( + "whitespace string", + r#"forced_chatgpt_workspace_id = " ""#, + None, + ), + ("empty list", r#"forced_chatgpt_workspace_id = []"#, None), + ( + "blank list entries", + r#"forced_chatgpt_workspace_id = ["", " "]"#, + None, + ), + ( + "mixed list entries", + r#"forced_chatgpt_workspace_id = ["", " 123e4567-e89b-42d3-a456-426614174000 ", "123e4567-e89b-42d3-a456-426614174001"]"#, + Some(vec![ + "123e4567-e89b-42d3-a456-426614174000", + "123e4567-e89b-42d3-a456-426614174001", + ]), + ), + ]; + + for (name, toml, expected) in cases { + let cfg_toml: ConfigToml = toml::from_str(toml) + .unwrap_or_else(|err| panic!("{name} should parse forced_chatgpt_workspace_id: {err}")); + let config = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await?; + + let expected = expected.map(|values| { + values + .into_iter() + .map(ToString::to_string) + .collect::>() + }); + assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}"); + } + + Ok(()) +} + #[tokio::test] async fn legacy_remote_thread_store_endpoint_is_rejected() { let cfg: ConfigToml = diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cc5203251c..16d8a57b30 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -752,15 +752,14 @@ pub struct Config { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, - /// Experimental / do not use. When set, app-server fetches thread-scoped /// config from a remote service at this endpoint. pub experimental_thread_config_endpoint: Option, /// Experimental / do not use. Selects the thread persistence backend. pub experimental_thread_store: ThreadStoreConfig, - /// When set, restricts ChatGPT login to a specific workspace identifier. - pub forced_chatgpt_workspace_id: Option, + /// When set, restricts ChatGPT login to one or more workspace identifiers. + pub forced_chatgpt_workspace_id: Option>, /// When set, restricts the login mechanism users may use. pub forced_login_method: Option, @@ -891,7 +890,7 @@ impl AuthManagerConfig for Config { self.cli_auth_credentials_store_mode } - fn forced_chatgpt_workspace_id(&self) -> Option { + fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id.clone() } @@ -2849,15 +2848,18 @@ impl Config { let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = - cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + let forced_chatgpt_workspace_id = cfg + .forced_chatgpt_workspace_id + .clone() + .map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec) + .map(|values| { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .filter(|values| !values.is_empty()); let forced_login_method = cfg.forced_login_method; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fe57be06fa..63ab3e0c42 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -22,6 +22,10 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; + #[tokio::test] async fn refresh_without_id_token() { let codex_home = tempdir().unwrap(); @@ -87,7 +91,7 @@ fn login_with_api_key_overwrites_existing_auth_json() { async fn login_with_access_token_writes_only_token() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; @@ -145,7 +149,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { #[tokio::test] async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) @@ -329,7 +333,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_mine".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) @@ -654,7 +658,7 @@ fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, ) -> AuthConfig { AuthConfig { codex_home: codex_home.to_path_buf(), @@ -712,7 +716,7 @@ fn remove_access_token_env_var() -> EnvVarGuard { #[serial(codex_auth_env)] async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); - let expected_record = agent_identity_record("account-123"); + let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) .expect("signed agent identity"); @@ -762,7 +766,7 @@ async fn load_auth_reads_access_token_from_env() { #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { let codex_home = tempdir().unwrap(); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); @@ -814,7 +818,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_another_org".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_DISALLOWED.to_string()), }, codex_home.path(), ) @@ -823,14 +827,17 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; let err = super::enforce_login_restrictions(&config) .await .expect_err("expected workspace mismatch to error"); - assert!(err.to_string().contains("workspace org_mine")); + assert!( + err.to_string() + .contains(&format!("workspace(s) {WORKSPACE_ID_ALLOWED}")) + ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" @@ -846,7 +853,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_mine".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) @@ -855,7 +862,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; @@ -870,6 +877,94 @@ async fn enforce_login_restrictions_allows_matching_workspace() { #[tokio::test] #[serial(codex_auth_env)] +async fn enforce_login_restrictions_allows_any_matching_workspace_in_list() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some(vec![ + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + WORKSPACE_ID_ALLOWED.to_string(), + ]), + ) + .await; + + super::enforce_login_restrictions(&config) + .await + .expect("any matching workspace in the allowed list should succeed"); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismatch() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-123", + }))) + .expect(1) + .mount(&server) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let _authapi_guard = + EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(agent_identity), + }, + AuthCredentialsStoreMode::File, + ) + .expect("seed agent identity auth"); + + let config = AuthConfig { + codex_home: codex_home.path().to_path_buf(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_login_method: None, + forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), + chatgpt_base_url: Some(chatgpt_base_url), + }; + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected workspace mismatch to error"); + assert!(err.to_string().contains(&format!( + "current credentials belong to {WORKSPACE_ID_DISALLOWED}" + ))); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + server.verify().await; +} + +#[tokio::test] async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); @@ -880,7 +975,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 45bd55302b..a2e4e8e0d8 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -610,8 +610,8 @@ pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, pub forced_login_method: Option, - pub forced_chatgpt_workspace_id: Option, pub chatgpt_base_url: Option, + pub forced_chatgpt_workspace_id: Option>, } pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { @@ -653,9 +653,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< } } - if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { - // workspace is the external identifier for account id. - let chatgpt_account_id = match auth { + if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { + let chatgpt_account_id = match &auth { CodexAuth::ApiKey(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { @@ -674,13 +673,21 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< token_data.id_token.chatgpt_account_id } }; - if chatgpt_account_id.as_deref() != Some(expected_account_id) { + + // workspace is the external identifier for account id. + let chatgpt_account_id = chatgpt_account_id.as_deref(); + if !chatgpt_account_id.is_some_and(|actual| { + expected_account_ids + .iter() + .any(|expected| expected == actual) + }) { + let expected_workspaces = expected_account_ids.join(", "); let message = match chatgpt_account_id { Some(actual) => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + "Login is restricted to workspace(s) {expected_workspaces}, but current credentials belong to {actual}. Logging out." ), None => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + "Login is restricted to workspace(s) {expected_workspaces}, but current credentials lack a workspace identifier. Logging out." ), }; return logout_with_message( @@ -1247,7 +1254,7 @@ pub struct AuthManager { inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: RwLock>, + forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, refresh_lock: Semaphore, external_auth: RwLock>>, @@ -1266,8 +1273,8 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; - /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. - fn forced_chatgpt_workspace_id(&self) -> Option; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option>; /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; @@ -1548,7 +1555,7 @@ impl AuthManager { } } - pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option>) { if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() && *guard != workspace_id { @@ -1556,7 +1563,7 @@ impl AuthManager { } } - pub fn forced_chatgpt_workspace_id(&self) -> Option { + pub fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id .read() .ok() @@ -1836,13 +1843,13 @@ impl AuthManager { "external auth refresh did not return ChatGPT metadata", ))); }; - if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() - && chatgpt_metadata.account_id != expected_workspace_id + if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref() + && !expected_workspace_ids.contains(&chatgpt_metadata.account_id) { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", - chatgpt_metadata.account_id, + "external auth refresh returned workspace {:?}, expected one of {:?}", + chatgpt_metadata.account_id, expected_workspace_ids, ), ))); } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index ad0d6b5e5e..b72bc946f2 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -69,7 +69,7 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -79,7 +79,7 @@ impl ServerOptions { pub fn new( codex_home: PathBuf, client_id: String, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { Self { @@ -486,7 +486,7 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_ids: Option<&[String]>, ) -> String { let mut query = vec![ ("response_type".to_string(), "code".to_string()), @@ -507,8 +507,8 @@ fn build_authorize_url( ("state".to_string(), state.to_string()), ("originator".to_string(), originator().value), ]; - if let Some(workspace_id) = forced_chatgpt_workspace_id { - query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + if let Some(workspace_ids) = forced_chatgpt_workspace_ids { + query.push(("allowed_workspace_id".to_string(), workspace_ids.join(","))); } let qs = query .into_iter() @@ -928,7 +928,7 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( - expected: Option<&str>, + expected: Option<&[String]>, id_token: &str, ) -> Result<(), String> { let Some(expected) = expected else { @@ -940,10 +940,13 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; - if actual == expected { + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { - Err(format!("Login is restricted to workspace id {expected}.")) + Err(format!( + "Login is restricted to workspace id(s) {}.", + expected.join(", ") + )) } } diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index bed94c7005..e575e6b8c7 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -23,6 +23,9 @@ use core_test_support::skip_if_no_network; // ---------- Small helpers ---------- +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; + fn make_jwt(payload: serde_json::Value) -> String { let header = json!({ "alg": "none", "typ": "JWT" }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -131,7 +134,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { let jwt = make_jwt(json!({ "https://api.openai.com/auth": { - "chatgpt_account_id": "acct_321" + "chatgpt_account_id": WORKSPACE_ID_ALLOWED } })); @@ -152,7 +155,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { assert_eq!(tokens.access_token, "access-token-123"); assert_eq!(tokens.refresh_token, "refresh-token-123"); assert_eq!(tokens.id_token.raw_jwt, jwt); - assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); + assert_eq!(tokens.account_id.as_deref(), Some(WORKSPACE_ID_ALLOWED)); Ok(()) } @@ -174,8 +177,8 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let jwt = make_jwt(json!({ "https://api.openai.com/auth": { - "chatgpt_account_id": "acct_321", - "organization_id": "org-actual" + "chatgpt_account_id": WORKSPACE_ID_DISALLOWED, + "organization_id": WORKSPACE_ID_DISALLOWED } })); @@ -183,7 +186,7 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let issuer = mock_server.uri(); let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); - opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + opts.forced_chatgpt_workspace_id = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let err = run_device_code_login(opts) .await diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index ce58a51358..bc7bb401e8 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -12,10 +12,15 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use tempfile::tempdir; +use url::Url; const DEFAULT_LOGIN_PORT: u16 = 1455; const FALLBACK_LOGIN_PORT: u16 = 1457; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; // See spawn.rs for details @@ -121,7 +126,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), - forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, }; let server = run_login_server(opts)?; @@ -165,7 +170,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -204,11 +209,52 @@ async fn creates_missing_codex_home_dir() -> Result<()> { Ok(()) } +#[tokio::test] +async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-multi".to_string(); + + let opts = ServerOptions { + codex_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state), + forced_chatgpt_workspace_id: Some(vec![ + WORKSPACE_ID_ALLOWED.to_string(), + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + ]), + codex_streamlined_login: false, + }; + let server = run_login_server(opts)?; + let auth_url = Url::parse(&server.auth_url)?; + let allowed_workspace_ids = auth_url + .query_pairs() + .filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned())) + .collect::>(); + assert_eq!( + allowed_workspace_ids, + vec![format!( + "{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}" + )] + ); + + Ok(()) +} + #[tokio::test] async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_DISALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -223,14 +269,14 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { port: 0, open_browser: false, force_state: Some(state.clone()), - forced_chatgpt_workspace_id: Some("org-required".to_string()), + forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, }; let server = run_login_server(opts)?; assert!( server .auth_url - .contains("allowed_workspace_id=org-required"), + .contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")), "auth URL should include forced workspace parameter" ); let login_port = server.actual_port; @@ -241,7 +287,9 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { assert!(resp.status().is_success()); let body = resp.text().await?; assert!( - body.contains("Login is restricted to workspace id org-required"), + body.contains(&format!( + "Login is restricted to workspace id(s) {WORKSPACE_ID_ALLOWED}" + )), "error body should mention workspace restriction" ); @@ -266,7 +314,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -334,7 +382,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -442,7 +490,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> }) }; - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -480,7 +528,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let first_tmp = tempdir()?; diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 1f84b289a7..270d64f5f7 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -16,7 +16,7 @@ pub(crate) struct LocalChatgptAuth { pub(crate) fn load_local_chatgpt_auth( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_id: Option<&[String]>, ) -> Result { let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode) .map_err(|err| format!("failed to load local auth: {err}"))? @@ -33,11 +33,11 @@ pub(crate) fn load_local_chatgpt_auth( .account_id .or(tokens.id_token.chatgpt_account_id.clone()) .ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?; - if let Some(expected_workspace) = forced_chatgpt_workspace_id - && chatgpt_account_id != expected_workspace + if let Some(expected_workspaces) = forced_chatgpt_workspace_id + && !expected_workspaces.contains(&chatgpt_account_id) { return Err(format!( - "local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}" + "local ChatGPT auth must use one of workspace(s) {expected_workspaces:?}, but found {chatgpt_account_id:?}", )); } @@ -122,7 +122,7 @@ mod tests { let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&["workspace-1".to_string()]), ) .expect("chatgpt auth should load"); @@ -186,7 +186,7 @@ mod tests { let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&["workspace-1".to_string(), "workspace-2".to_string()]), ) .expect("managed auth should win"); @@ -202,7 +202,7 @@ mod tests { let auth = load_local_chatgpt_auth( codex_home.path(), AuthCredentialsStoreMode::File, - Some("workspace-1"), + Some(&["workspace-1".to_string()]), ) .expect("chatgpt auth should load"); From d346957288a5637b2bc1220c2ba476c02dcbb7d5 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 14 May 2026 14:45:53 -0700 Subject: [PATCH 03/51] make rust-release-prepare use env secret (#22702) made a `rust-release-prepare` environment with the necessary API key as an environment secret. use this in the workflow rather than the action secret. once this merges and i confirm it works as intended, ill rm the action secret. --- .github/workflows/rust-release-prepare.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml index e998efb54c..67e542efa9 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -16,6 +16,9 @@ jobs: prepare: # Prevent scheduled runs on forks (no secrets, wastes Actions minutes) if: github.repository == 'openai/codex' + environment: + name: rust-release-prepare + deployment: false runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 8c7a176b55d9949774954508e4a504298f98df82 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Thu, 14 May 2026 15:07:20 -0700 Subject: [PATCH 04/51] Unqueue plugin list and read requests (#22703) ## Summary - remove the app-server `plugin-read` serialization queue from `plugin/list` and `plugin/read` - allow plugin read/list requests to start immediately instead of waiting behind other plugin read/list requests ## Test plan - `just fmt` - `cargo test -p codex-app-server-protocol` --- .../app-server-protocol/src/protocol/common.rs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e63b435344..89f60f979b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -621,12 +621,12 @@ client_request_definitions! { }, PluginList => "plugin/list" { params: v2::PluginListParams, - serialization: global_shared_read("plugin-read"), + serialization: None, response: v2::PluginListResponse, }, PluginRead => "plugin/read" { params: v2::PluginReadParams, - serialization: global_shared_read("plugin-read"), + serialization: None, response: v2::PluginReadResponse, }, PluginSkillRead => "plugin/skill/read" { @@ -1700,12 +1700,7 @@ mod tests { marketplace_kinds: None, }, }; - assert_eq!( - plugin_list.serialization_scope(), - Some(ClientRequestSerializationScope::GlobalSharedRead( - "plugin-read" - )) - ); + assert_eq!(plugin_list.serialization_scope(), None); let plugin_read = ClientRequest::PluginRead { request_id: request_id(), @@ -1715,12 +1710,7 @@ mod tests { plugin_name: "plugin-a".to_string(), }, }; - assert_eq!( - plugin_read.serialization_scope(), - Some(ClientRequestSerializationScope::GlobalSharedRead( - "plugin-read" - )) - ); + assert_eq!(plugin_read.serialization_scope(), None); let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), From 7c11c14efc693c19f59dadfccc7abcdc35bbaf89 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 14 May 2026 15:54:18 -0700 Subject: [PATCH 05/51] Fix Windows sandbox clippy clones (#22687) ## Summary - remove two redundant `PathBuf` clones in Windows sandbox setup tests - fix current `rust-ci-full` Windows clippy failures on `main` ## Validation - `just fmt` - attempted on `dev`: `cargo clippy --target x86_64-pc-windows-msvc --tests --profile dev --timings -- -D warnings` - blocked by missing MSVC cross toolchain on the Linux devbox (`lib.exe` / MSVC C toolchain unavailable) - live failure evidence: main `rust-ci-full` runs 25880209898 and 25879137967 failed on `windows-sandbox-rs/src/bin/setup_main/win.rs` with `clippy::redundant_clone` at the two edited callsites --- codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index 1f93d0a5ee..f0475bc39f 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -1034,7 +1034,7 @@ mod tests { let deny_sids = workspace_write_cap_sids_for_path( &codex_home, &workspace, - &[workspace.clone(), active_root.clone()], + &[workspace.clone(), active_root], &deny_path, ) .expect("deny sids"); @@ -1070,7 +1070,7 @@ mod tests { let deny_sids = workspace_write_cap_sids_for_path( &codex_home, &workspace, - &[workspace.clone(), active_root.clone()], + &[workspace.clone(), active_root], &deny_path, ) .expect("deny sids"); From 51b0e94105c46bca4540b4f2a488e7e9d91bd9e0 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 14 May 2026 16:15:56 -0700 Subject: [PATCH 06/51] chore(features) rm Feature::ApplyPatchFreeform (#22711) ## Summary Removes the feature since this is effectively on by default in all cases where we should use it, or can be configured via models.json. ## Testing - [x] unit tests pass --- .../app-server/tests/suite/v2/turn_start.rs | 15 +++++++- codex-rs/core/config.schema.json | 6 --- codex-rs/core/src/config/config_tests.rs | 5 --- codex-rs/core/src/config/managed_features.rs | 2 - codex-rs/core/src/config/mod.rs | 9 ----- codex-rs/core/src/guardian/review_session.rs | 2 - codex-rs/core/src/tools/spec_tests.rs | 1 - codex-rs/core/tests/common/test_codex.rs | 7 ---- codex-rs/core/tests/suite/apply_patch_cli.rs | 14 +------ codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/code_mode.rs | 32 ++-------------- codex-rs/core/tests/suite/codex_delegate.rs | 1 - codex-rs/core/tests/suite/hooks.rs | 6 --- codex-rs/core/tests/suite/prompt_caching.rs | 4 -- codex-rs/core/tests/suite/remote_env.rs | 5 +-- .../tests/suite/request_permissions_tool.rs | 1 - codex-rs/core/tests/suite/shell_command.rs | 4 +- .../core/tests/suite/shell_serialization.rs | 37 ++++--------------- codex-rs/core/tests/suite/shell_snapshot.rs | 1 - codex-rs/core/tests/suite/tool_harness.rs | 15 +------- codex-rs/core/tests/suite/tools.rs | 1 - codex-rs/core/tests/suite/unified_exec.rs | 1 - codex-rs/features/src/legacy.rs | 11 ------ codex-rs/features/src/lib.rs | 11 +++--- codex-rs/features/src/tests.rs | 35 +++++++++++++++--- codex-rs/thread-manager-sample/src/main.rs | 1 - codex-rs/tools/src/tool_config.rs | 6 +-- codex-rs/tools/src/tool_config_tests.rs | 8 +--- 28 files changed, 71 insertions(+), 172 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 62192ebd08..314be8a6ff 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2476,13 +2476,26 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { &server.uri(), "never", &BTreeMap::from([ - (Feature::ApplyPatchFreeform, true), (Feature::ApplyPatchStreamingEvents, true), (Feature::Plugins, false), (Feature::RemoteModels, false), (Feature::ShellSnapshot, false), ]), )?; + write_models_cache(&codex_home)?; + let cache_path = codex_home.join("models_cache.json"); + let mut cache: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?; + let models = cache["models"] + .as_array_mut() + .expect("models_cache.json models should be an array"); + let model = models + .first_mut() + .expect("models_cache.json should contain at least one model"); + model["slug"] = serde_json::Value::from("mock-model"); + model["display_name"] = serde_json::Value::from("mock-model"); + model["apply_patch_tool_type"] = serde_json::Value::from("freeform"); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 84ce98ffc5..2c0acbf895 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -457,9 +457,6 @@ "in_app_browser": { "type": "boolean" }, - "include_apply_patch_tool": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, @@ -4224,9 +4221,6 @@ "in_app_browser": { "type": "boolean" }, - "include_apply_patch_tool": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c2ef25e6cb..571f882735 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4320,7 +4320,6 @@ async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { .await?; assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); - assert!(!config.include_apply_patch_tool); Ok(()) } @@ -7505,7 +7504,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -7954,7 +7952,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -8117,7 +8114,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -8265,7 +8261,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 78b88d2f3a..480225fb99 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -348,12 +348,10 @@ pub(crate) fn validate_feature_requirements_in_config_toml( let configured_features = Features::from_sources( FeatureConfigSource { features: cfg.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: profile.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: profile.experimental_use_unified_exec_tool, }, FeatureOverrides::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 16d8a57b30..fa48bfde86 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -764,11 +764,6 @@ pub struct Config { /// When set, restricts the login mechanism users may use. pub forced_login_method: Option, - /// Include the `apply_patch` tool for models that benefit from invoking - /// file edits as a structured tool call. When unset, this falls back to the - /// model info's default preference. - pub include_apply_patch_tool: bool, - /// Explicit or feature-derived web search mode. pub web_search_mode: Constrained, @@ -2301,12 +2296,10 @@ impl Config { let configured_features = Features::from_sources( FeatureConfigSource { features: cfg.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: config_profile.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: config_profile .experimental_use_unified_exec_tool, }, @@ -2845,7 +2838,6 @@ impl Config { config }; - let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let forced_chatgpt_workspace_id = cfg @@ -3257,7 +3249,6 @@ impl Config { experimental_thread_store: thread_store_config(cfg.experimental_thread_store), forced_chatgpt_workspace_id, forced_login_method, - include_apply_patch_tool: include_apply_patch_tool_flag, web_search_mode: constrained_web_search_mode.value, web_search_config, use_experimental_unified_exec_tool, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index d3fae0f2e5..253ead5b41 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -151,7 +151,6 @@ struct GuardianReviewSessionReuseKey { main_execve_wrapper_exe: Option, zsh_path: Option, features: ManagedFeatures, - include_apply_patch_tool: bool, use_experimental_unified_exec_tool: bool, } @@ -176,7 +175,6 @@ impl GuardianReviewSessionReuseKey { main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), zsh_path: spawn_config.zsh_path.clone(), features: spawn_config.features.clone(), - include_apply_patch_tool: spawn_config.include_apply_patch_tool, use_experimental_unified_exec_tool: spawn_config.use_experimental_unified_exec_tool, } } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index ca8739ccff..8653810ba4 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1162,7 +1162,6 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_info = construct_model_info_offline("gpt-5.4", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::ApplyPatchFreeform); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index fd53e828a9..947111d3fc 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -24,7 +24,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use codex_extension_api::empty_extension_registry; -use codex_features::Feature; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; @@ -581,12 +580,6 @@ impl TestCodexBuilder { } ensure_test_model_catalog(&mut config)?; - if config.include_apply_patch_tool { - config.features.enable(Feature::ApplyPatchFreeform)?; - } else { - config.features.disable(Feature::ApplyPatchFreeform)?; - } - Ok((config, cwd)) } } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b692d32568..9185b8a7bd 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -59,9 +59,7 @@ pub async fn apply_patch_harness() -> Result { async fn apply_patch_harness_with( configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, ) -> Result { - let builder = configure(test_codex()).with_config(|config| { - config.include_apply_patch_tool = true; - }); + let builder = configure(test_codex()); // Box harness construction so apply_patch_cli tests do not inline the // full test-thread startup path into each test future. Box::pin(TestCodexHarness::with_remote_env_builder(builder)).await @@ -989,15 +987,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( ) -> Result<()> { skip_if_no_network!(Ok(())); - let harness = apply_patch_harness_with(|builder| { - builder.with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }) - }) - .await?; + let harness = apply_patch_harness().await?; // Compose a patch that would create a file, then fail verification on an update. let call_id = "apply-partial-no-side-effects"; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 439eab5be2..5045755e32 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1408,7 +1408,7 @@ fn scenarios() -> Vec { content: "freeform-patch-danger", }, sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![Feature::ApplyPatchFreeform], + features: vec![], model_override: Some("gpt-5.4"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index af63d092f8..ff7640f05e 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -144,13 +144,11 @@ async fn run_code_mode_turn( server: &MockServer, prompt: &str, code: &str, - include_apply_patch: bool, ) -> Result<(TestCodex, ResponseMock)> { let mut builder = test_codex() .with_model("test-gpt-5.1-codex") .with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); - config.include_apply_patch_tool = include_apply_patch; }); let test = builder.build(server).await?; @@ -291,7 +289,6 @@ async fn code_mode_can_return_exec_command_output() -> Result<()> { r#" text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marker" }))); "#, - /*include_apply_patch*/ false, ) .await?; @@ -541,7 +538,6 @@ const result = await tools.update_plan({ }); text(JSON.stringify(result)); "#, - /*include_apply_patch*/ false, ) .await?; @@ -666,7 +662,6 @@ text(JSON.stringify(await tools.exec_command({ max_output_tokens: 100 }))); "#, - /*include_apply_patch*/ false, ) .await?; @@ -705,7 +700,6 @@ text("before crash"); text("still before crash"); throw new Error("boom"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -752,7 +746,6 @@ try { text(`caught:${error?.message ?? String(error)}`); } "#, - /*include_apply_patch*/ false, ) .await?; @@ -1769,7 +1762,6 @@ async fn code_mode_can_output_serialized_text_via_global_helper() -> Result<()> r#" text({ json: true }); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1801,7 +1793,6 @@ async fn code_mode_can_resume_after_set_timeout() -> Result<()> { await new Promise((resolve) => setTimeout(resolve, 10)); text("timer done"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1830,7 +1821,6 @@ notify("code_mode_notify_marker"); await tools.test_sync_tool({}); text("done"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1868,7 +1858,6 @@ text("before"); exit(); text("after"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1907,7 +1896,6 @@ const circular = {}; circular.self = circular; text(circular); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1947,7 +1935,6 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { image("https://example.com/image.jpg"); image("data:image/png;base64,AAA"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -2137,13 +2124,8 @@ async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> { ); let code = format!("text(await tools.apply_patch({patch:?}));\n"); - let (test, second_mock) = run_code_mode_turn( - &server, - "use exec to run apply_patch", - &code, - /*include_apply_patch*/ true, - ) - .await?; + let (test, second_mock) = + run_code_mode_turn(&server, "use exec to run apply_patch", &code).await?; let req = second_mock.single_request(); let items = custom_tool_output_items(&req, "call-1"); @@ -2465,13 +2447,8 @@ const tool = ALL_TOOLS.find(({ name }) => name === "view_image"); text(JSON.stringify(tool)); "#; - let (_test, second_mock) = run_code_mode_turn( - &server, - "use exec to inspect ALL_TOOLS", - code, - /*include_apply_patch*/ false, - ) - .await?; + let (_test, second_mock) = + run_code_mode_turn(&server, "use exec to inspect ALL_TOOLS", code).await?; let req = second_mock.single_request(); let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); @@ -2890,7 +2867,6 @@ text(JSON.stringify({ waited_long_enough: end_ms - start_ms >= 100, })); "#, - /*include_apply_patch*/ false, ) .await?; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index cc31aa5c77..f5db9856df 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -153,7 +153,6 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { .permissions .set_permission_profile(PermissionProfile::read_only()) .expect("set permission profile"); - config.include_apply_patch_tool = true; }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index f059998052..53649afff8 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1658,7 +1658,6 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2880,7 +2879,6 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2959,7 +2957,6 @@ async fn pre_tool_use_rewrites_apply_patch_before_execution() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3025,7 +3022,6 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3627,7 +3623,6 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3715,7 +3710,6 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<( } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 5b861ab219..f887131eef 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -231,10 +231,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config - .features - .disable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 9c3414baeb..e19379ffc8 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -347,9 +347,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< }; let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex(); let test = builder.build_with_remote_and_local_env(&server).await?; let local_cwd = TempDir::new()?; let file_name = "apply_patch_remote_freeform.txt"; @@ -435,7 +433,6 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.approvals_reviewer = ApprovalsReviewer::User; }); diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 30b887ab08..cb13243966 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -340,7 +340,6 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu let permission_profile_for_config = permission_profile.clone(); let mut builder = test_codex().with_config(move |config| { - config.include_apply_patch_tool = true; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .permissions diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 1b3b3d0b40..eb545fce0b 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -61,9 +61,7 @@ fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec TestCodexBuilder, ) -> Result { - let builder = configure(test_codex()).with_config(|config| { - config.include_apply_patch_tool = true; - }); + let builder = configure(test_codex()); TestCodexHarness::with_builder(builder).await } diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 56afa59b73..dbe4957ecb 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -72,15 +72,10 @@ fn shell_responses( fn configure_shell_model( builder: TestCodexBuilder, output_type: ShellModelOutput, - include_apply_patch_tool: bool, ) -> TestCodexBuilder { - let builder = match output_type { + match output_type { ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), - }; - - builder.with_config(move |config| { - config.include_apply_patch_tool = include_apply_patch_tool; - }) + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -91,11 +86,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -139,11 +130,7 @@ async fn shell_output_structures_fixture_with_serialization( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); @@ -200,11 +187,7 @@ async fn shell_output_for_freeform_tool_records_duration( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -474,11 +457,7 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex() - .with_model("gpt-5.4") - .with_config(move |config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex().with_model("gpt-5.4"); let test = builder.build(&server).await?; let call_id = "shell-nonzero-exit"; @@ -512,9 +491,7 @@ async fn shell_command_output_is_freeform() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex(); let test = builder.build(&server).await?; let call_id = "shell-command"; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index f757fa322a..49ef4956cd 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -531,7 +531,6 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { .features .enable(Feature::ShellSnapshot) .expect("test config should allow feature update"); - config.include_apply_patch_tool = true; }); let harness = TestCodexHarness::with_builder(builder).await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ac41d06c3f..38843f2d83 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -3,7 +3,6 @@ use std::fs; use assert_matches::assert_matches; -use codex_features::Feature; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::plan_tool::StepStatus; @@ -324,12 +323,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }); + let mut builder = test_codex(); let TestCodex { codex, cwd, @@ -467,12 +461,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }); + let mut builder = test_codex(); let TestCodex { codex, cwd, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 0f46e50386..7b523a4686 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -71,7 +71,6 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> .features .enable(Feature::UnifiedExec) .expect("unified exec should enable for test"); - config.include_apply_patch_tool = true; }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 231d14f770..eeccd5b465 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -235,7 +235,6 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { skip_if_windows!(Ok(())); let builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; config.use_experimental_unified_exec_tool = true; if let Err(err) = config.features.enable(Feature::UnifiedExec) { panic!("test config should allow feature update: {err}"); diff --git a/codex-rs/features/src/legacy.rs b/codex-rs/features/src/legacy.rs index c3dcb089a5..1a8b3d24dc 100644 --- a/codex-rs/features/src/legacy.rs +++ b/codex-rs/features/src/legacy.rs @@ -21,10 +21,6 @@ const ALIASES: &[Alias] = &[ legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, }, - Alias { - legacy_key: "include_apply_patch_tool", - feature: Feature::ApplyPatchFreeform, - }, Alias { legacy_key: "request_permissions", feature: Feature::ExecPermissionApprovals, @@ -67,18 +63,11 @@ pub(crate) fn feature_for_key(key: &str) -> Option { #[derive(Debug, Default)] pub(crate) struct LegacyFeatureToggles { - pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, } impl LegacyFeatureToggles { pub fn apply(self, features: &mut Features) { - set_if_some( - features, - Feature::ApplyPatchFreeform, - self.include_apply_patch_tool, - "include_apply_patch_tool", - ); set_if_some( features, Feature::UnifiedExec, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index da74d21e94..194ad8b6c2 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -99,7 +99,7 @@ pub enum Feature { ShellZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, - /// Include the freeform apply_patch tool. + /// Removed compatibility flag for the deleted apply_patch fallback feature. ApplyPatchFreeform, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, @@ -292,7 +292,6 @@ pub struct FeatureOverrides { #[derive(Debug, Clone, Copy, Default)] pub struct FeatureConfigSource<'a> { pub features: Option<&'a FeaturesToml>, - pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, } @@ -424,6 +423,9 @@ impl Features { "remote_control" => { continue; } + "apply_patch_freeform" => { + continue; + } "image_detail_original" => { continue; } @@ -465,7 +467,6 @@ impl Features { for source in [base, profile] { LegacyFeatureToggles { - include_apply_patch_tool: source.include_apply_patch_tool, experimental_use_unified_exec_tool: source.experimental_use_unified_exec_tool, } .apply(&mut features); @@ -822,8 +823,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::ApplyPatchStreamingEvents, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 21a6ecf5ba..bd55ef8e7d 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -66,6 +66,16 @@ fn image_detail_original_is_removed_and_disabled_by_default() { assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); } +#[test] +fn apply_patch_freeform_is_removed_and_disabled_by_default() { + assert_eq!(Feature::ApplyPatchFreeform.stage(), Stage::Removed); + assert_eq!(Feature::ApplyPatchFreeform.default_enabled(), false); + assert_eq!( + feature_for_key("apply_patch_freeform"), + Some(Feature::ApplyPatchFreeform) + ); +} + #[test] fn code_mode_only_requires_code_mode() { let mut features = Features::with_defaults(); @@ -403,7 +413,6 @@ fn from_sources_applies_base_profile_and_overrides() { }, FeatureConfigSource { features: Some(&profile_features), - include_apply_patch_tool: Some(true), ..Default::default() }, FeatureOverrides { @@ -414,7 +423,7 @@ fn from_sources_applies_base_profile_and_overrides() { assert_eq!(features.enabled(Feature::Plugins), true); assert_eq!(features.enabled(Feature::CodeModeOnly), true); assert_eq!(features.enabled(Feature::CodeMode), true); - assert_eq!(features.enabled(Feature::ApplyPatchFreeform), true); + assert_eq!(features.enabled(Feature::ApplyPatchFreeform), false); assert_eq!(features.enabled(Feature::WebSearchRequest), false); } @@ -472,6 +481,23 @@ fn from_sources_ignores_removed_js_repl_feature_keys() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_apply_patch_freeform_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("apply_patch_freeform".to_string(), true)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn multi_agent_v2_feature_config_deserializes_boolean_toggle() { let features: FeaturesToml = toml::from_str( @@ -587,14 +613,13 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( proxy_url: Some("http://127.0.0.1:43128".to_string()), ..Default::default() })), - entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]), + entries: BTreeMap::new(), ..Default::default() }; features_toml.materialize_resolved_enabled(&features); let entries = features_toml.entries(); - assert_eq!(entries.get("include_apply_patch_tool"), None); for spec in crate::FEATURES { assert_eq!( entries.get(spec.key), @@ -627,7 +652,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( FeatureConfigSource::default(), FeatureOverrides::default(), ); - assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), true); + assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), false); } #[test] diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index fff914eff3..a3a9adf266 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -262,7 +262,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R experimental_thread_store: ThreadStoreConfig::Local, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Disabled), web_search_config: None, use_experimental_unified_exec_tool: false, diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index ad884e5be0..6e3ce15526 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -175,7 +175,6 @@ impl ToolsConfig { session_source, .. } = params; - let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_code_mode = features.enabled(Feature::CodeMode); let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); let include_goal_tools = features.enabled(Feature::Goals); @@ -225,10 +224,7 @@ impl ToolsConfig { model_shell_type }; - let apply_patch_tool_type = model_info - .apply_patch_tool_type - .clone() - .or_else(|| include_apply_patch_tool.then_some(ApplyPatchToolType::Freeform)); + let apply_patch_tool_type = model_info.apply_patch_tool_type.clone(); let agent_jobs_worker_tools = include_agent_jobs && matches!( diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 252ad7a320..2fa49b03bf 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -4,7 +4,6 @@ use codex_features::Features; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; @@ -156,7 +155,7 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { } #[test] -fn fallback_apply_patch_models_use_freeform_tool_by_default() { +fn fallback_apply_patch_models_do_not_use_freeform_tool_by_default() { let model_info = model_info(); let features = Features::with_defaults(); @@ -172,10 +171,7 @@ fn fallback_apply_patch_models_use_freeform_tool_by_default() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - assert_eq!( - tools_config.apply_patch_tool_type, - Some(ApplyPatchToolType::Freeform) - ); + assert_eq!(tools_config.apply_patch_tool_type, None); } #[test] From 85915a2a2119d1da143dea6a236c5dee8fe9af04 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 14 May 2026 16:25:15 -0700 Subject: [PATCH 07/51] chore(config) rm windows_wsl_setup_acknowledged (#22717) ## Summary Remove dead code from a notice that no longer exists. ## Testing - [x] Unit tests pass. --- codex-rs/config/src/config_toml.rs | 3 --- codex-rs/core/config.schema.json | 4 ---- codex-rs/core/src/config/config_tests.rs | 4 ---- codex-rs/core/src/config/edit.rs | 13 ------------- codex-rs/core/src/config/mod.rs | 4 ---- codex-rs/thread-manager-sample/src/main.rs | 1 - 6 files changed, 29 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 055df69529..e2c9bfeb0f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -481,9 +481,6 @@ pub struct ConfigToml { #[serde(default)] pub windows: Option, - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: Option, - /// Collection of in-product notices (different from notifications) /// See [`crate::types::Notice`] for more details pub notice: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2c0acbf895..dd2b01d0af 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4793,10 +4793,6 @@ "default": null, "description": "Windows-specific configuration." }, - "windows_wsl_setup_acknowledged": { - "description": "Tracks whether the Windows onboarding screen has been acknowledged.", - "type": "boolean" - }, "zsh_path": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 571f882735..7760c2d32d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7514,7 +7514,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -7962,7 +7961,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -8124,7 +8122,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -8271,7 +8268,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 80bf4d9bf1..5418d3b268 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -44,8 +44,6 @@ pub enum ConfigEdit { SetNoticeFastDefaultOptOut(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), - /// Toggle the Windows onboarding acknowledgement flag. - SetWindowsWslSetupAcknowledged(bool), /// Toggle the model migration prompt acknowledgement flag. SetNoticeHideModelMigrationPrompt(String, bool), /// Toggle the home external config migration prompt acknowledgement flag. @@ -645,11 +643,6 @@ impl ConfigDocument { &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), - ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( - Scope::Global, - &["windows_wsl_setup_acknowledged"], - value(*acknowledged), - )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => { Ok(self.add_tool_suggest_disabled_tool(disabled_tool)) @@ -1240,12 +1233,6 @@ impl ConfigEditsBuilder { self } - pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); - self - } - pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap) -> Self { self.edits .extend(model_availability_nux_count_edits(shown_count)); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fa48bfde86..c6b35e7a01 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -797,9 +797,6 @@ pub struct Config { /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd pub active_project: ProjectConfig, - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: bool, - /// Collection of various notices we show the user pub notices: Notice, @@ -3261,7 +3258,6 @@ impl Config { .unwrap_or(false), active_profile: active_profile_name, active_project, - windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index a3a9adf266..4dde737922 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -272,7 +272,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R suppress_unstable_features_warning: false, active_profile: None, active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Notice::default(), check_for_update_on_startup: false, disable_paste_burst: false, From 3dc278b68ea476e03d54a605df8fe52d4a0cef88 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 14 May 2026 16:54:59 -0700 Subject: [PATCH 08/51] Trim TUI legacy core helper usage (#22695) ## Why The TUI still had a few low-risk dependencies flowing through the transitional `legacy_core` namespace after the app-server migration. These helpers either already have clearer non-core owners or are presentation logic that does not belong in `codex-core`, so moving them out reduces the compatibility surface without changing product behavior. ## What changed This is a low-risk change, almost completely mechanical in nature. - Route TUI Codex-home lookup through `codex-utils-home-dir`, use `Config::log_dir` directly, and call `codex-sandboxing::system_bwrap_warning` without going through `legacy_core`. - Move shared `codex resume` hint formatting from `codex-core` into `codex-utils-cli`. - Update CLI and TUI call sites to use the shared CLI utility, and keep the resume-command behavior covered by tests in its new home. ## Verification - `cargo test -p codex-utils-cli` - `cargo test -p codex-utils-cli resume_command` --- codex-rs/Cargo.lock | 3 + codex-rs/cli/src/main.rs | 5 +- codex-rs/core/src/util.rs | 19 ------ codex-rs/core/src/util_tests.rs | 38 ----------- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 3 +- codex-rs/tui/src/app/startup_prompts.rs | 6 +- codex-rs/tui/src/chatwidget.rs | 5 +- .../tui/src/chatwidget/settings_popups.rs | 2 +- codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui/src/main.rs | 6 +- codex-rs/tui/src/session_log.rs | 5 +- codex-rs/utils/cli/Cargo.toml | 1 + codex-rs/utils/cli/src/lib.rs | 2 + codex-rs/utils/cli/src/resume_command.rs | 64 +++++++++++++++++++ 15 files changed, 87 insertions(+), 78 deletions(-) create mode 100644 codex-rs/utils/cli/src/resume_command.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 20197567ab..4d1f66008f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3785,6 +3785,7 @@ dependencies = [ "codex-protocol", "codex-realtime-webrtc", "codex-rollout", + "codex-sandboxing", "codex-shell-command", "codex-state", "codex-terminal-detection", @@ -3794,6 +3795,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-elapsed", "codex-utils-fuzzy-match", + "codex-utils-home-dir", "codex-utils-oss", "codex-utils-path", "codex-utils-plugins", @@ -3913,6 +3915,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-protocol", + "codex-shell-command", "pretty_assertions", "serde", "toml 0.9.11+spec-1.1.0", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6e1ccac867..de5f52f4e7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -38,6 +38,7 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; +use codex_utils_cli::resume_command; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -628,9 +629,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Option { } } -pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { - let resume_target = thread_name - .filter(|name| !name.is_empty()) - .map(str::to_string) - .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); - resume_target.map(|target| { - let needs_double_dash = target.starts_with('-'); - let escaped = shlex_join(&[target]); - if needs_double_dash { - format!("codex resume -- {escaped}") - } else { - format!("codex resume {escaped}") - } - }) -} - #[cfg(test)] #[path = "util_tests.rs"] mod tests; diff --git a/codex-rs/core/src/util_tests.rs b/codex-rs/core/src/util_tests.rs index 48dd6fc9a0..fdc7535159 100644 --- a/codex-rs/core/src/util_tests.rs +++ b/codex-rs/core/src/util_tests.rs @@ -432,41 +432,3 @@ fn normalize_thread_name_trims_and_rejects_empty() { Some("my thread".to_string()) ); } - -#[test] -fn resume_command_prefers_name_over_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(Some("my-thread"), Some(thread_id)); - assert_eq!(command, Some("codex resume my-thread".to_string())); -} - -#[test] -fn resume_command_with_only_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(/*thread_name*/ None, Some(thread_id)); - assert_eq!( - command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) - ); -} - -#[test] -fn resume_command_with_no_name_or_id() { - let command = resume_command(/*thread_name*/ None, /*thread_id*/ None); - assert_eq!(command, None); -} - -#[test] -fn resume_command_quotes_thread_name_when_needed() { - let command = resume_command(Some("-starts-with-dash"), /*thread_id*/ None); - assert_eq!( - command, - Some("codex resume -- -starts-with-dash".to_string()) - ); - - let command = resume_command(Some("two words"), /*thread_id*/ None); - assert_eq!(command, Some("codex resume 'two words'".to_string())); - - let command = resume_command(Some("quote'case"), /*thread_id*/ None); - assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); -} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index abd1ccb5c6..c213e92bae 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -52,6 +52,7 @@ codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-realtime-webrtc = { workspace = true } codex-rollout = { workspace = true } +codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } @@ -60,6 +61,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } codex-utils-fuzzy-match = { workspace = true } +codex-utils-home-dir = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-path = { workspace = true } codex-utils-plugins = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7a74e0f956..77154d3e5a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -397,8 +397,7 @@ fn session_summary( let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); let thread_id = resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); - let resume_command = - crate::legacy_core::util::resume_command(/*thread_name*/ None, thread_id); + let resume_command = codex_utils_cli::resume_command(/*thread_name*/ None, thread_id); if usage_line.is_none() && resume_command.is_none() { return None; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index aa281d4533..c04c16fdc8 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -66,9 +66,9 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config } pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { - let Some(message) = crate::legacy_core::config::system_bwrap_warning( - config.permissions.permission_profile.get(), - ) else { + let Some(message) = + codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile.get()) + else { return; }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 57adb065b9..5c7d0194ef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -165,6 +165,7 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::resume_command; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -1433,8 +1434,8 @@ impl ChatWidget { } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let resume_cmd = crate::legacy_core::util::resume_command(Some(name), thread_id) - .unwrap_or_else(|| format!("codex resume {name}")); + let resume_cmd = + resume_command(Some(name), thread_id).unwrap_or_else(|| format!("codex resume {name}")); let name = name.to_string(); let line = vec![ "• ".into(), diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index cdd38ad3a8..d718e70d79 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -7,7 +7,7 @@ use super::*; impl ChatWidget { pub(super) fn open_theme_picker(&mut self) { - let codex_home = crate::legacy_core::config::find_codex_home().ok(); + let codex_home = codex_utils_home_dir::find_codex_home().ok(); let terminal_width = self .last_rendered_width .get() diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 32ca6617f7..47ff11d374 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,7 +7,6 @@ use crate::legacy_core::check_execpolicy_for_warnings; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; -use crate::legacy_core::config::find_codex_home; use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; @@ -56,6 +55,7 @@ use codex_rollout::state_db; use codex_state::log_db; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; +use codex_utils_home_dir::find_codex_home; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use color_eyre::eyre::WrapErr; @@ -1073,7 +1073,7 @@ pub async fn run_main( } } - let log_dir = crate::legacy_core::config::log_dir(&config)?; + let log_dir = config.log_dir.clone(); std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. let mut log_file_opts = OpenOptions::new(); diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 8574b16f8a..e41d5f5a7d 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,5 +1,4 @@ use clap::Parser; -use codex_app_server_client::legacy_core; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_config::LoaderOverrides; @@ -8,6 +7,7 @@ use codex_tui::Cli; use codex_tui::ExitReason; use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::resume_command; use supports_color::Stream; fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { @@ -22,9 +22,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec dir, - Err(_) => std::env::temp_dir(), - }; + let mut p = config.log_dir.clone(); let filename = format!( "session-{}.jsonl", chrono::Utc::now().format("%Y%m%dT%H%M%SZ") diff --git a/codex-rs/utils/cli/Cargo.toml b/codex-rs/utils/cli/Cargo.toml index 1ade005e3b..9d330c5ce6 100644 --- a/codex-rs/utils/cli/Cargo.toml +++ b/codex-rs/utils/cli/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] clap = { workspace = true, features = ["derive", "wrap_help"] } codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } serde = { workspace = true } toml = { workspace = true } diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 98d2318d9b..32ef52e095 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -1,6 +1,7 @@ mod approval_mode_cli_arg; mod config_override; pub(crate) mod format_env_display; +mod resume_command; mod sandbox_mode_cli_arg; mod shared_options; @@ -8,5 +9,6 @@ pub use approval_mode_cli_arg::ApprovalModeCliArg; pub use codex_protocol::config_types::ProfileV2Name; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; +pub use resume_command::resume_command; pub use sandbox_mode_cli_arg::SandboxModeCliArg; pub use shared_options::SharedCliOptions; diff --git a/codex-rs/utils/cli/src/resume_command.rs b/codex-rs/utils/cli/src/resume_command.rs new file mode 100644 index 0000000000..52b88e0e33 --- /dev/null +++ b/codex-rs/utils/cli/src/resume_command.rs @@ -0,0 +1,64 @@ +//! Shared formatting for user-facing `codex resume` command hints. + +use codex_protocol::ThreadId; +use codex_shell_command::parse_command::shlex_join; + +pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { + let resume_target = thread_name + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); + resume_target.map(|target| { + let needs_double_dash = target.starts_with('-'); + let escaped = shlex_join(&[target]); + if needs_double_dash { + format!("codex resume -- {escaped}") + } else { + format!("codex resume {escaped}") + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn prefers_name_over_id() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(Some("my-thread"), Some(thread_id)); + assert_eq!(command, Some("codex resume my-thread".to_string())); + } + + #[test] + fn formats_thread_id_when_name_is_missing() { + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let command = resume_command(/*thread_name*/ None, Some(thread_id)); + assert_eq!( + command, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); + } + + #[test] + fn returns_none_without_a_resume_target() { + let command = resume_command(/*thread_name*/ None, /*thread_id*/ None); + assert_eq!(command, None); + } + + #[test] + fn quotes_thread_names_when_needed() { + let command = resume_command(Some("-starts-with-dash"), /*thread_id*/ None); + assert_eq!( + command, + Some("codex resume -- -starts-with-dash".to_string()) + ); + + let command = resume_command(Some("two words"), /*thread_id*/ None); + assert_eq!(command, Some("codex resume 'two words'".to_string())); + + let command = resume_command(Some("quote'case"), /*thread_id*/ None); + assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); + } +} From 66af2178656f8b385e1c61ec84148576ce955a81 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 14 May 2026 17:25:32 -0700 Subject: [PATCH 09/51] Fix /review mode MCP startup render issue (#21624) This change fixes the case where the UI can sit on _"Starting MCP servers"_ even though the review work is already running or has already completed. - MCP startup status header is visible when a `/review` turn starts with enabled MCP server startups - Restore the underlying _Working..._ status after MCP startup completes or fails - Add regression coverage for overlapping startup/turn flows and status restoration _De-scoped from a broader thread-scoped MCP status change that would have made it easier to route MCP startup statuses to the appropriate thread (parent vs. review). These changes address the UI regression without requiring more significant changes across app-server & core._ Fixes #18792. --- codex-rs/tui/src/chatwidget/mcp_startup.rs | 23 +++- .../tui/src/chatwidget/tests/mcp_startup.rs | 116 ++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 4 +- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index cf499a455b..05d3def329 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use super::ChatWidget; +const MCP_STARTUP_SINGLE_HEADER_PREFIX: &str = "Booting MCP server:"; +const MCP_STARTUP_MULTI_HEADER_PREFIX: &str = "Starting MCP servers"; + #[derive(Debug, Clone)] pub(crate) enum McpStartupStatus { Starting, @@ -153,11 +156,11 @@ impl ChatWidget { } let header = if total > 1 { format!( - "Starting MCP servers ({completed}/{total}): {}", + "{MCP_STARTUP_MULTI_HEADER_PREFIX} ({completed}/{total}): {}", to_show.join(", ") ) } else { - format!("Booting MCP server: {first}") + format!("{MCP_STARTUP_SINGLE_HEADER_PREFIX} {first}") }; self.set_status_header(header); } @@ -187,12 +190,16 @@ impl ChatWidget { self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); } + let mcp_startup_owned_status = self.status_header_is_mcp_startup_owned(); self.mcp_startup_status = None; self.mcp_startup_ignore_updates_until_next_start = true; self.mcp_startup_allow_terminal_only_next_round = false; self.mcp_startup_pending_next_round.clear(); self.mcp_startup_pending_next_round_saw_starting = false; self.update_task_running_state(); + if self.bottom_pane.is_task_running() && mcp_startup_owned_status { + self.restore_reasoning_status_header(); + } self.maybe_send_next_queued_input(); self.request_redraw(); } @@ -234,6 +241,18 @@ impl ChatWidget { self.finish_mcp_startup(failed, cancelled); } + pub(super) fn status_header_is_mcp_startup_owned(&self) -> bool { + self.status_state + .current_status + .header + .starts_with(MCP_STARTUP_SINGLE_HEADER_PREFIX) + || self + .status_state + .current_status + .header + .starts_with(MCP_STARTUP_MULTI_HEADER_PREFIX) + } + pub(super) fn on_mcp_server_status_updated( &mut self, notification: McpServerStatusUpdatedNotification, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index da4777bfb1..ba862a5293 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -57,6 +57,46 @@ async fn mcp_startup_complete_does_not_clear_running_task() { assert!(chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_preserves_active_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_replaces_idle_completed_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert!(!chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!(chat.status_state.current_status.header, "Working"); } #[tokio::test] @@ -125,6 +165,82 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { ); } +#[tokio::test] +async fn mcp_startup_failure_restores_running_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Starting MCP servers") + ); + + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); + let _ = drain_insert_history(&mut rx); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn mcp_startup_complete_preserves_review_status() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Booting MCP server") + ); + + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, + }); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!( + chat.status_state.current_status.header, + "Reviewing approval request" + ); + assert_eq!( + chat.status_state.current_status.details, + Some("rm -rf '/tmp/guardian target'".to_string()) + ); +} + #[tokio::test] async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 077c33606d..d82eca338f 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -66,7 +66,9 @@ impl ChatWidget { self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); + if self.mcp_startup_status.is_none() || !self.status_header_is_mcp_startup_owned() { + self.set_status_header(String::from("Working")); + } self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.set_ambient_pet_notification( From 4bff020a96623b91769d7d8619e5454645e1e530 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 14 May 2026 17:40:32 -0700 Subject: [PATCH 10/51] Remove SSE fixture loaders (#22684) ## Why The Responses API test support already has structured SSE event builders. Keeping separate JSON fixture loaders made small mock streams harder to read and left an on-disk fixture for a single event. ## What changed - Removed `load_sse_fixture` and `load_sse_fixture_with_id_from_str` from `core_test_support`. - Deleted the one `tests/fixtures/incomplete_sse.json` Responses API fixture. - Replaced the remaining call sites with `responses::sse(...)` and existing event helpers. ## Validation - `cargo test -p codex-core --test all stream_no_completed::retries_on_early_close` - `cargo test -p codex-core --test all history_dedupes_streamed_and_final_messages_across_turns` - `cargo test -p codex-core --test all review::` --- codex-rs/core/tests/common/lib.rs | 48 ------- .../core/tests/fixtures/incomplete_sse.json | 3 - codex-rs/core/tests/suite/client.rs | 26 ++-- codex-rs/core/tests/suite/review.rs | 128 +++++++----------- .../core/tests/suite/stream_no_completed.rs | 8 +- 5 files changed, 63 insertions(+), 150 deletions(-) delete mode 100644 codex-rs/core/tests/fixtures/incomplete_sse.json diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 70e1a3f0e4..ad7858f804 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -237,54 +237,6 @@ pub fn find_codex_linux_sandbox_exe() -> Result { codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox") } -/// Builds an SSE stream body from a JSON fixture. -/// -/// The fixture must contain an array of objects where each object represents a -/// single SSE event with at least a `type` field matching the `event:` value. -/// Additional fields become the JSON payload for the `data:` line. An object -/// with only a `type` field results in an event with no `data:` section. This -/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or -/// fields. -pub fn load_sse_fixture(path: impl AsRef) -> String { - let events: Vec = - serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) - .expect("parse JSON fixture"); - events - .into_iter() - .map(|e| { - let kind = e - .get("type") - .and_then(|v| v.as_str()) - .expect("fixture event missing type"); - if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { - format!("event: {kind}\n\n") - } else { - format!("event: {kind}\ndata: {e}\n\n") - } - }) - .collect() -} - -pub fn load_sse_fixture_with_id_from_str(raw: &str, id: &str) -> String { - let replaced = raw.replace("__ID__", id); - let events: Vec = - serde_json::from_str(&replaced).expect("parse JSON fixture"); - events - .into_iter() - .map(|e| { - let kind = e - .get("type") - .and_then(|v| v.as_str()) - .expect("fixture event missing type"); - if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { - format!("event: {kind}\n\n") - } else { - format!("event: {kind}\ndata: {e}\n\n") - } - }) - .collect() -} - pub async fn wait_for_event( codex: &CodexThread, predicate: F, diff --git a/codex-rs/core/tests/fixtures/incomplete_sse.json b/codex-rs/core/tests/fixtures/incomplete_sse.json deleted file mode 100644 index 2876bbfd29..0000000000 --- a/codex-rs/core/tests/fixtures/incomplete_sse.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - {"type": "response.output_item.done"} -] diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8a461edc63..42bacb9bdc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -52,6 +52,7 @@ use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_message_item_added; @@ -3016,22 +3017,15 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let server = MockServer::start().await; // Build a small SSE stream with deltas and a final assistant message. - // We emit the same body for all 3 turns; ids vary but are unused by assertions. - let sse_raw = r##"[ - {"type":"response.output_item.added", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":""}] - }}, - {"type":"response.output_text.delta", "delta":"Hey "}, - {"type":"response.output_text.delta", "delta":"there"}, - {"type":"response.output_text.delta", "delta":"!\n"}, - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"Hey there!\n"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"##; - let sse1 = core_test_support::load_sse_fixture_with_id_from_str(sse_raw, "resp1"); + // We emit the same body for all 3 turns. + let sse1 = sse(vec![ + ev_message_item_added("msg-1", ""), + ev_output_text_delta("Hey "), + ev_output_text_delta("there"), + ev_output_text_delta("!\n"), + ev_assistant_message("msg-1", "Hey there!\n"), + ev_completed("resp1"), + ]); let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1.clone(), sse1]).await; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f371cdacad..3611df6a6a 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -18,7 +18,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; -use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; @@ -61,17 +61,11 @@ async fn review_op_emits_lifecycle_and_review_output() { "overall_confidence_score": 0.8 }) .to_string(); - let sse_template = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":__REVIEW__}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let review_json_escaped = serde_json::to_string(&review_json).unwrap(); - let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); - let (server, _request_log) = - start_responses_server_with_sse(&sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse(&review_json), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -186,15 +180,11 @@ async fn review_op_emits_lifecycle_and_review_output() { async fn review_op_with_plain_text_emits_review_fallback() { skip_if_no_network!(); - let sse_raw = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"just plain text"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, _request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse("just plain text"), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -240,22 +230,17 @@ async fn review_op_with_plain_text_emits_review_fallback() { async fn review_filters_agent_message_related_events() { skip_if_no_network!(); - // Stream simulating a typing assistant message with deltas and finalization. - let sse_raw = r#"[ - {"type":"response.output_item.added", "item":{ - "type":"message", "role":"assistant", "id":"msg-1", - "content":[{"type":"output_text","text":""}] - }}, - {"type":"response.output_text.delta", "delta":"Hi"}, - {"type":"response.output_text.delta", "delta":" there"}, - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", "id":"msg-1", - "content":[{"type":"output_text","text":"Hi there"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, _request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + vec![ + responses::ev_message_item_added("msg-1", ""), + responses::ev_output_text_delta("Hi"), + responses::ev_output_text_delta(" there"), + responses::ev_assistant_message("msg-1", "Hi there"), + responses::ev_completed("resp-1"), + ], + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -325,17 +310,11 @@ async fn review_does_not_emit_agent_message_on_structured_output() { "overall_confidence_score": 0.5 }) .to_string(); - let sse_template = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":__REVIEW__}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let review_json_escaped = serde_json::to_string(&review_json).unwrap(); - let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); - let (server, _request_log) = - start_responses_server_with_sse(&sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse(&review_json), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -386,12 +365,8 @@ async fn review_does_not_emit_agent_message_on_structured_output() { async fn review_uses_custom_review_model_from_config() { skip_if_no_network!(); - // Minimal stream: just a completed event - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { @@ -441,12 +416,8 @@ async fn review_uses_custom_review_model_from_config() { async fn review_uses_session_model_when_review_model_unset() { skip_if_no_network!(); - // Minimal stream: just a completed event - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); @@ -496,12 +467,8 @@ async fn review_uses_session_model_when_review_model_unset() { async fn review_input_isolated_from_parent_history() { skip_if_no_network!(); - // Mock server for the single review request - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; // Seed a parent session history via resume file with both user + assistant items. let codex_home = Arc::new(TempDir::new().unwrap()); @@ -674,16 +641,11 @@ async fn review_input_isolated_from_parent_history() { async fn review_history_surfaces_in_parent_session() { skip_if_no_network!(); - // Respond to both the review request and the subsequent parent request. - let sse_raw = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"review assistant output"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 2).await; + let (server, request_log) = start_responses_server_with_sse( + assistant_message_sse("review assistant output"), + /*expected_requests*/ 2, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -776,9 +738,8 @@ async fn review_history_surfaces_in_parent_session() { async fn review_uses_overridden_cwd_for_base_branch_merge_base() { skip_if_no_network!(); - let sse_raw = r#"[{"type":"response.completed", "response": {"id": "__ID__"}}]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let initial_cwd = TempDir::new().unwrap(); @@ -881,13 +842,24 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { server.verify().await; } -/// Start a mock Responses API server and mount the given SSE stream body. +fn assistant_message_sse(text: &str) -> Vec { + vec![ + responses::ev_assistant_message("msg-1", text), + responses::ev_completed("resp-1"), + ] +} + +fn completed_sse() -> Vec { + vec![responses::ev_completed("resp-1")] +} + +/// Start a mock Responses API server and mount the given SSE events. async fn start_responses_server_with_sse( - sse_raw: &str, + events: Vec, expected_requests: usize, ) -> (MockServer, ResponseMock) { let server = start_mock_server().await; - let sse = load_sse_fixture_with_id_from_str(sse_raw, &Uuid::new_v4().to_string()); + let sse = responses::sse(events); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; (server, request_log) diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 984220a086..30574718f2 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -6,8 +6,6 @@ use codex_model_provider_info::WireApi; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; -use codex_utils_cargo_bin::find_resource; -use core_test_support::load_sse_fixture; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::streaming_sse::StreamingSseChunk; @@ -17,9 +15,9 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; fn sse_incomplete() -> String { - let fixture = find_resource!("tests/fixtures/incomplete_sse.json") - .unwrap_or_else(|err| panic!("failed to resolve incomplete_sse fixture: {err}")); - load_sse_fixture(fixture) + responses::sse(vec![serde_json::json!({ + "type": "response.output_item.done", + })]) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From eeabaf74ea16a4e88f8813f341d7bc087ca731c8 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 14 May 2026 17:53:13 -0700 Subject: [PATCH 11/51] [codex] Group removed feature flags (#22730) ## Summary - move removed feature enum variants under the existing Removed section - keep active feature variants grouped away from no-op compatibility flags ## Test plan - just fmt - cargo test -p codex-features Co-authored-by: Codex --- codex-rs/features/src/lib.rs | 88 ++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 194ad8b6c2..59f723683b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -76,31 +76,22 @@ impl Stage { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { // Stable. - /// Removed compatibility flag retained as a no-op so old configs can - /// still parse `undo`. - GhostCommit, /// Enable the default shell tool. ShellTool, /// Enable Claude-style lifecycle hooks loaded from hooks.json files. CodexHooks, // Experimental - /// Removed compatibility flag for the deleted JavaScript REPL feature. - JsRepl, /// Enable JavaScript code mode backed by the in-process V8 runtime. CodeMode, /// Restrict model-visible tools to code mode entrypoints (`exec`, `wait`). CodeModeOnly, - /// Removed compatibility flag for the deleted JavaScript REPL tool-only mode. - JsReplToolsOnly, /// Use the single unified PTY-backed exec tool. UnifiedExec, /// Route shell tool execution through the zsh exec bridge. ShellZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, - /// Removed compatibility flag for the deleted apply_patch fallback feature. - ApplyPatchFreeform, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -112,30 +103,13 @@ pub enum Feature { /// Allow the model to request web searches that fetch cached content. /// Takes precedence over `WebSearchRequest`. WebSearchCached, - /// Legacy search-tool feature flag kept for backward compatibility. - SearchTool, - /// Removed legacy Linux bubblewrap opt-in flag retained as a no-op so old - /// wrappers and config can still parse it. - UseLinuxSandboxBwrap, /// Use the legacy Landlock Linux sandbox fallback instead of the default /// bubblewrap pipeline. UseLegacyLandlock, - /// Allow the model to request approval and propose exec rules. - RequestRule, - /// Enable Windows sandbox (restricted token) on Windows. - WindowsSandbox, - /// Use the elevated Windows sandbox pipeline (setup + runner). - WindowsSandboxElevated, - /// Legacy remote models flag kept for backward compatibility. - RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, - /// Removed legacy git commit attribution guidance flag. - CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, - /// Persist rollout metadata to a local SQLite database. - Sqlite, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, /// Enable the Chronicle sidecar for passive screen-context memories. @@ -162,8 +136,6 @@ pub enum Feature { ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. ToolSearchAlwaysDeferMcpTools, - /// Removed compatibility flag for the deleted unavailable-tool placeholder backfill. - UnavailableDummyTools, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -200,18 +172,12 @@ pub enum Feature { SkillEnvVarDependencyPrompt, /// Enable the unified mention popup prototype. MentionsV2, - /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. - /// Kept for config backward compatibility; behavior is always steer-enabled. - Steer, /// Allow request_user_input in Default collaboration mode. DefaultModeRequestUserInput, /// Enable automatic review for approval prompts. GuardianApproval, /// Enable persisted thread goals and automatic goal continuation. Goals, - /// Enable collaboration modes (Plan, Default). - /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. - CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, /// Prompt Codex Apps connector auth failures through MCP URL elicitations. @@ -224,15 +190,57 @@ pub enum Feature { FastMode, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, - /// Connect app-server to the ChatGPT remote control service. + /// Prevent idle system sleep while a turn is actively running. + PreventIdleSleep, + /// Send `response.processed` over Responses API websockets after a turn response is recorded. + ResponsesWebsocketResponseProcessed, + /// Enable remote compaction v2 over the normal Responses API. + RemoteCompactionV2, + /// Enable workspace dependency support. + WorkspaceDependencies, + + // Removed + /// Removed compatibility flag retained as a no-op so old configs can + /// still parse `undo`. + GhostCommit, + /// Removed compatibility flag for the deleted JavaScript REPL feature. + JsRepl, + /// Removed compatibility flag for the deleted JavaScript REPL tool-only mode. + JsReplToolsOnly, + /// Legacy search-tool feature flag kept for backward compatibility. + SearchTool, + /// Removed legacy Linux bubblewrap opt-in flag retained as a no-op so old + /// wrappers and config can still parse it. + UseLinuxSandboxBwrap, + /// Allow the model to request approval and propose exec rules. + RequestRule, + /// Enable Windows sandbox (restricted token) on Windows. + WindowsSandbox, + /// Use the elevated Windows sandbox pipeline (setup + runner). + WindowsSandboxElevated, + /// Legacy remote models flag kept for backward compatibility. + RemoteModels, + /// Removed legacy git commit attribution guidance flag. + CodexGitCommit, + /// Persist rollout metadata to a local SQLite database. + Sqlite, + /// Removed compatibility flag for the deleted apply_patch fallback feature. + ApplyPatchFreeform, + /// Removed compatibility flag for the deleted unavailable-tool placeholder backfill. + UnavailableDummyTools, + /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. + /// Kept for config backward compatibility; behavior is always steer-enabled. + Steer, + /// Enable collaboration modes (Plan, Default). + /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. + CollaborationModes, + /// Removed compatibility flag for the deleted remote control feature. RemoteControl, /// Removed compatibility flag retained as a no-op so old wrappers can /// still pass `--enable image_detail_original`. ImageDetailOriginal, /// Removed compatibility flag. The TUI now always uses the app-server implementation. TuiAppServer, - /// Prevent idle system sleep while a turn is actively running. - PreventIdleSleep, /// Removed compatibility flag retained as a no-op now that workspace owner /// usage nudges are always enabled. WorkspaceOwnerUsageNudge, @@ -240,12 +248,6 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, - /// Send `response.processed` over Responses API websockets after a turn response is recorded. - ResponsesWebsocketResponseProcessed, - /// Enable remote compaction v2 over the normal Responses API. - RemoteCompactionV2, - /// Enable workspace dependency support. - WorkspaceDependencies, } impl Feature { From 7dbe1c949889e2edd21cbdbabc6dcb2d2b2c1bcd Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 14 May 2026 18:04:26 -0700 Subject: [PATCH 12/51] [codex] Remove experimental instructions file config (#22724) ## Summary Remove the deprecated `experimental_instructions_file` config setting from the typed config surface and the remaining deprecation-notice plumbing. `model_instructions_file` remains the supported setting and its loading path is unchanged. The setting was deprecated when it was renamed to `model_instructions_file` on January 20, 2026 in https://github.com/openai/codex/pull/9555. ## Changes - Remove `experimental_instructions_file` from `ConfigToml` and `ConfigProfile`. - Delete the custom config-layer scan and session deprecation notice for the removed setting. - Stop clearing the removed field from generated session config locks. - Remove the obsolete deprecation-notice test case while keeping `model_instructions_file` coverage intact. ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-config` - `cargo test -p codex-core model_instructions_file` - `just fix -p codex-core` - `git diff --check` Co-authored-by: Codex --- codex-rs/config/src/config_toml.rs | 4 -- codex-rs/config/src/profile_toml.rs | 3 - codex-rs/core/src/config/mod.rs | 24 ------- codex-rs/core/src/session/config_lock.rs | 1 - codex-rs/core/src/session/session.rs | 13 ---- .../core/tests/suite/deprecation_notice.rs | 63 ------------------- 6 files changed, 108 deletions(-) diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index e2c9bfeb0f..851ec52bfd 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -485,10 +485,6 @@ pub struct ConfigToml { /// See [`crate::types::Notice`] for more details pub notice: Option, - /// Legacy, now use features - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index e8e6320490..6cf35be68e 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -50,9 +50,6 @@ pub struct ConfigProfile { pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c6b35e7a01..0587da83e2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3407,13 +3407,6 @@ impl Config { } } -pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { - config_layer_stack - .layers_high_to_low() - .into_iter() - .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) -} - fn guardian_policy_config_from_requirements( requirements_toml: &ConfigRequirementsToml, ) -> Option { @@ -3427,23 +3420,6 @@ fn normalize_guardian_policy_config(value: Option<&str>) -> Option { }) } -fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { - let Some(table) = value.as_table() else { - return false; - }; - if table.contains_key("experimental_instructions_file") { - return true; - } - let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { - return false; - }; - profiles.values().any(|profile| { - profile.as_table().is_some_and(|profile_table| { - profile_table.contains_key("experimental_instructions_file") - }) - }) -} - /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 1e632ba0cf..85815f8533 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -186,7 +186,6 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.profiles.clear(); clear_config_lock_debug_controls(lock_config); lock_config.model_instructions_file = None; - lock_config.experimental_instructions_file = None; lock_config.experimental_compact_prompt_file = None; lock_config.model_catalog_json = None; lock_config.sandbox_mode = None; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5b1b8c83e4..f7c7a91ca1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -564,19 +564,6 @@ impl Session { }), }); } - if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { - post_session_configured_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { - summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." - .to_string(), - details: Some( - "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." - .to_string(), - ), - }), - }); - } for message in &config.startup_warnings { post_session_configured_events.push(Event { id: "".to_owned(), diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 36b5fe9d5d..c41ff47f2a 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -1,23 +1,16 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; -use codex_app_server_protocol::ConfigLayerSource; -use codex_config::ConfigLayerEntry; -use codex_config::ConfigLayerStack; -use codex_config::ConfigRequirements; -use codex_config::ConfigRequirementsToml; use codex_features::Feature; use codex_protocol::protocol::DeprecationNoticeEvent; use codex_protocol::protocol::EventMsg; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::test_absolute_path; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use std::collections::BTreeMap; -use toml::Value as TomlValue; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()> { @@ -60,62 +53,6 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - let mut table = toml::map::Map::new(); - table.insert( - "experimental_instructions_file".to_string(), - TomlValue::String("legacy.md".to_string()), - ); - let config_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { - file: test_absolute_path("/tmp/config.toml"), - profile: None, - }, - TomlValue::Table(table), - ); - let config_layer_stack = ConfigLayerStack::new( - vec![config_layer], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("build config layer stack"); - config.config_layer_stack = config_layer_stack; - }); - - let TestCodex { codex, .. } = builder.build(&server).await?; - - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) - if ev.summary.contains("experimental_instructions_file") => - { - Some(ev.clone()) - } - _ => None, - }) - .await; - - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." - .to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." - ), - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From c25d905f61ca96ed575cd24e82fc4295465c66d0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 18:25:23 -0700 Subject: [PATCH 13/51] permissions: support workspace roots in profiles (#22610) ## Why This is the configuration/model half of the alternative permissions migration we discussed as a comparison point for [#22401](https://github.com/openai/codex/pull/22401) and [#22402](https://github.com/openai/codex/pull/22402). The old `workspace-write` model mixes three concerns that we want to keep separate: - reusable profile rules that should stay immutable once selected - user/runtime workspace roots from `cwd`, `--add-dir`, and legacy workspace-write config - internal Codex writable roots such as memories, which should not be shown as user workspace roots This PR gives permission profiles first-class `workspace_roots` so users can opt multiple repositories into the same `:workspace_roots` rules without using broad absolute-path write grants. It also starts separating the raw selected profile from the effective runtime profile by making `Permissions` expose explicit accessors instead of public mutable fields. A representative `config.toml` looks like this: ```toml default_permissions = "dev" [permissions.dev.workspace_roots] "~/code/openai" = true "~/code/developers-website" = true [permissions.dev.filesystem.":workspace_roots"] "." = "write" ".codex" = "read" ".git" = "read" ".vscode" = "read" ``` If Codex starts in `~/code/codex` with that profile selected, the effective workspace-root set becomes: - `~/code/codex` from the runtime `cwd` - `~/code/openai` from the profile - `~/code/developers-website` from the profile The `:workspace_roots` rules are materialized across each root, so `.git`, `.codex`, and `.vscode` stay scoped the same way everywhere. Runtime additions such as `--add-dir` can still layer on later stack entries without mutating the selected profile. ## Stack Shape This PR intentionally stops before the profile-identity cleanup in [#22683](https://github.com/openai/codex/pull/22683) so the base review stays focused on config loading, workspace-root materialization, and compatibility with legacy `workspace-write`. The representation in this PR is therefore transitional: `Permissions` carries enough state to distinguish the raw constrained profile from the effective runtime profile, and there are still call sites that must keep the active profile identity and constrained profile value in sync. The follow-up PR replaces that with a single resolved profile state (`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the profile id, immutable `PermissionProfile`, and profile-declared workspace roots together. That follow-up removes APIs such as `set_constrained_permission_profile_with_active_profile()` where separate arguments could drift out of sync. Downstream PRs then build on this base to switch app-server turn updates to profile ids plus runtime workspace roots and to finish the user-visible summary behavior. Reviewers should judge this PR as the workspace-roots foundation, not as the final in-memory shape of selected permission profiles. ## Review Guide Suggested review order: 1. Start with `codex-rs/core/src/config/mod.rs`. This is the main shape change in the base slice. `Permissions` now stores a private raw `Constrained` plus runtime `workspace_roots`. Callers use `permission_profile()` when they need the raw constrained value and `effective_permission_profile()` when they need a materialized runtime profile. As noted above, [#22683](https://github.com/openai/codex/pull/22683) replaces this transitional shape with a resolved profile state that keeps identity and profile data together. 2. Review `codex-rs/config/src/permissions_toml.rs` and `codex-rs/core/src/config/permissions.rs`. These add `[permissions..workspace_roots]`, resolve enabled entries relative to the policy cwd, and keep `:workspace_roots` deny-read glob patterns symbolic until the actual roots are known. 3. Review `codex-rs/protocol/src/permissions.rs` and `codex-rs/protocol/src/models.rs`. These add the policy/profile materialization helpers that expand exact `:workspace_roots` entries and scoped deny-read globs over every workspace root. This is also where `ActivePermissionProfileModification` is removed from the core model. 4. Review the legacy bridge in `Config::load_from_base_config_with_overrides` and `Config::set_legacy_sandbox_policy`. This is where legacy `workspace-write` roots become runtime workspace roots, while Codex internal writable roots stay internal and do not appear as user-facing workspace roots. 5. Then skim downstream call sites. The interesting pattern is raw-vs-effective access: state/proxy/bwrap paths keep the raw constrained profile, while execution, summaries, and user-visible status use the effective profile and workspace-root list. ## What Changed - added `[permissions..workspace_roots]` to the config model and schema - added runtime `workspace_roots` state to `Config`/`Permissions` and `ConfigOverrides` - made `Permissions` profile fields private and replaced direct mutation with accessors/setters - added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for materializing `:workspace_roots` exact paths and deny-read globs across all roots - moved legacy additional writable roots into runtime workspace-root state instead of active profile modifications - removed `ActivePermissionProfileModification` and its app-server protocol/schema export - updated sandbox/status summary paths so internal writable roots are not reported as user workspace roots ## Verification Strategy The targeted tests cover the behavior at the layers where regressions are most likely: - `codex-rs/core/src/config/config_tests.rs` verifies config loading, legacy workspace-root seeding, effective profile materialization, and memory-root handling. - `codex-rs/core/src/config/permissions_tests.rs` verifies profile `workspace_roots` parsing and `:workspace_roots` scoped/glob compilation. - `codex-rs/protocol/src/permissions.rs` unit tests verify exact and glob materialization over multiple workspace roots. - `codex-rs/tui/src/status/tests.rs` and `codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the user-facing summaries show effective workspace roots and hide internal writes. I also ran `cargo check --tests` locally after the latest stack refresh to catch cross-crate API breakage from the private-field/accessor changes. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610). * #22612 * #22611 * #22683 * __->__ #22610 --- codex-rs/Cargo.lock | 1 + .../codex_app_server_protocol.schemas.json | 33 -- .../codex_app_server_protocol.v2.schemas.json | 33 -- .../schema/json/v2/ThreadForkResponse.json | 33 -- .../schema/json/v2/ThreadResumeResponse.json | 33 -- .../schema/json/v2/ThreadStartResponse.json | 33 -- .../typescript/v2/ActivePermissionProfile.ts | 8 +- .../v2/ActivePermissionProfileModification.ts | 6 - .../schema/typescript/v2/index.ts | 1 - .../src/protocol/v2/permissions.rs | 46 --- codex-rs/app-server/src/lib.rs | 2 +- .../command_exec_processor.rs | 8 +- .../request_processors/thread_processor.rs | 2 +- .../src/request_processors/turn_processor.rs | 2 +- codex-rs/cli/src/debug_sandbox.rs | 38 ++- codex-rs/config/src/permissions_toml.rs | 15 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 236 ++++++++++--- codex-rs/core/src/config/mod.rs | 319 +++++++++++++----- codex-rs/core/src/config/permissions.rs | 56 ++- codex-rs/core/src/config/permissions_tests.rs | 36 ++ .../src/context/permissions_instructions.rs | 3 +- codex-rs/core/src/guardian/review_session.rs | 6 +- codex-rs/core/src/guardian/tests.rs | 8 +- codex-rs/core/src/session/mod.rs | 16 +- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 57 +++- codex-rs/core/src/session/turn_context.rs | 10 +- .../src/tools/handlers/multi_agents_tests.rs | 4 +- .../core/tests/suite/permissions_messages.rs | 5 +- codex-rs/core/tests/suite/unified_exec.rs | 18 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 8 +- codex-rs/exec/Cargo.toml | 1 + .../src/event_processor_with_human_output.rs | 87 +---- ...event_processor_with_human_output_tests.rs | 26 +- codex-rs/exec/src/lib.rs | 35 +- codex-rs/exec/src/lib_tests.rs | 24 ++ codex-rs/protocol/src/models.rs | 46 ++- codex-rs/protocol/src/permissions.rs | 228 ++++++++++++- codex-rs/thread-manager-sample/src/main.rs | 19 +- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 12 +- codex-rs/tui/src/app/event_dispatch.rs | 5 +- codex-rs/tui/src/app/startup_prompts.rs | 2 +- codex-rs/tui/src/app/tests.rs | 18 +- codex-rs/tui/src/app/thread_routing.rs | 5 +- codex-rs/tui/src/app/thread_session_state.rs | 12 +- codex-rs/tui/src/app_server_session.rs | 91 +++-- .../tui/src/chatwidget/input_submission.rs | 2 +- .../tui/src/chatwidget/permission_popups.rs | 2 +- codex-rs/tui/src/chatwidget/session_flow.rs | 29 +- .../tui/src/chatwidget/status_surfaces.rs | 8 +- .../src/chatwidget/tests/history_replay.rs | 70 +++- .../src/chatwidget/windows_sandbox_prompts.rs | 13 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/status/card.rs | 88 +++-- codex-rs/tui/src/status/tests.rs | 97 ++++-- codex-rs/utils/sandbox-summary/Cargo.toml | 1 + .../sandbox-summary/src/sandbox_summary.rs | 73 +++- 60 files changed, 1355 insertions(+), 731 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4d1f66008f..50de8dc946 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2715,6 +2715,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e996ca5417..8bcd2edd6d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5607,14 +5607,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5622,31 +5614,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7f73f9fe07..a7b8a26007 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 6e74ab4ac8..4eb85f4ed3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 727b7a3fb2..312d289e41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index bf03f0fb55..c363f2e78d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index cbc8c6ef0a..73f9efcab5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type ActivePermissionProfile = { /** @@ -13,9 +12,4 @@ id: string, * Parent profile identifier once permissions profiles support * inheritance. This is currently always `null`. */ -extends: string | null, -/** - * Bounded user-requested modifications applied on top of the named - * profile, if any. - */ -modifications: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 984154ba04..0a6d868ad0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..0796ee4e89 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -437,41 +436,6 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } } impl From for ActivePermissionProfile { @@ -479,11 +443,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,11 +452,6 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 350acf002f..f2ac6ca00a 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -589,7 +589,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) { config_warnings.push(ConfigWarningNotification { summary: warning, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..d1781db5ff 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -164,7 +164,7 @@ impl CommandExecRequestProcessor { let started_network_proxy = match self.config.permissions.network.as_ref() { Some(spec) => match spec .start_proxy( - self.config.permissions.permission_profile.get(), + self.config.permissions.permission_profile().get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -243,7 +243,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile() .can_set(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile @@ -264,12 +264,12 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile() .can_set(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; permission_profile } else { - self.config.permissions.permission_profile() + self.config.permissions.effective_permission_profile() }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 92da7cdd8f..85b2e57571 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -977,7 +977,7 @@ impl ThreadRequestProcessor { let requested_permissions_trust_project = requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d1dae4ef46..110406cc9e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -413,7 +413,7 @@ impl TurnRequestProcessor { ))); } ( - Some(config.permissions.permission_profile()), + Some(config.permissions.effective_permission_profile()), config.permissions.active_permission_profile(), ) } else { diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30c60d7b95..bdcf0a191f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -230,7 +230,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile.get(), + config.permissions.permission_profile().get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -285,7 +285,7 @@ async fn run_command_under_sandbox( let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), @@ -962,10 +962,19 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .get() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) @@ -996,10 +1005,19 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .get() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abb..fff8c67706 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -25,10 +25,25 @@ impl PermissionsToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct WorkspaceRootsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl WorkspaceRootsToml { + pub fn enabled_roots(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|(path, enabled)| (*enabled).then_some(path)) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct FilesystemPermissionsToml { /// Optional maximum depth for expanding unreadable glob patterns on diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dd2b01d0af..d802fa4e65 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1965,6 +1965,9 @@ }, "network": { "$ref": "#/definitions/NetworkToml" + }, + "workspace_roots": { + "$ref": "#/definitions/WorkspaceRootsToml" } }, "type": "object" @@ -3960,6 +3963,9 @@ "type": "string" } ] + }, + "WorkspaceRootsToml": { + "type": "object" } }, "description": "Base config deserialized from ~/.codex/config.toml.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7760c2d32d..2e077bae35 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -29,6 +29,7 @@ use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; @@ -70,7 +71,6 @@ use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -722,6 +722,10 @@ fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "workspace" +[permissions.workspace.workspace_roots] +"~/code/openai" = true +"~/code/ignored" = false + [permissions.workspace.filesystem] ":minimal" = "read" @@ -748,6 +752,12 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("~/code/ignored".to_string(), false), + ("~/code/openai".to_string(), true), + ]), + }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -803,6 +813,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -956,6 +967,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1106,6 +1118,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1208,6 +1221,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1256,6 +1270,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1302,6 +1317,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -1335,6 +1351,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: ) .await?; + let cwd_root = cwd.path().abs(); let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), @@ -1346,14 +1363,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + path: FileSystemPath::Path { + path: cwd_root.clone(), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some("docs".into())), + path: FileSystemPath::Path { + path: cwd_root.join("docs"), }, access: FileSystemAccessMode::Read, }, @@ -1374,6 +1391,12 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: exclude_slash_tmp: true, } ); + assert!( + !config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()) + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1406,7 +1429,10 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), @@ -1436,7 +1462,10 @@ async fn permission_profile_override_preserves_managed_unrestricted_filesystem() ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ExternalSandbox { @@ -1568,6 +1597,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1605,7 +1635,10 @@ async fn permission_profile_override_preserves_configured_network_policy_without config.permissions.network.is_none(), "profile network.enabled should not start the managed network proxy" ); - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); Ok(()) } @@ -1613,7 +1646,9 @@ async fn permission_profile_override_preserves_configured_network_policy_without async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; + tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?; let config = Config::load_from_base_config_with_overrides( ConfigToml { @@ -1622,6 +1657,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( @@ -1640,6 +1676,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), @@ -1653,21 +1690,23 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: .glob_scan_max_depth, Some(2) ); - let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) - .to_string_lossy() - .into_owned(); - assert!( - config - .permissions - .file_system_sandbox_policy() - .entries - .contains(&FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: expected_pattern, - }, - access: FileSystemAccessMode::None, - }) - ); + for root in [cwd.path(), extra_root.path()] { + let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root) + .to_string_lossy() + .into_owned(); + assert!( + config + .permissions + .file_system_sandbox_policy() + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: expected_pattern, + }, + access: FileSystemAccessMode::None, + }) + ); + } assert!( !config .permissions @@ -1697,6 +1736,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1767,8 +1807,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() --> std::io::Result<()> { +async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; @@ -1790,20 +1829,88 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi let policy = config.permissions.file_system_sandbox_policy(); assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + !policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}" ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY).with_modifications( - vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )) + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let cwd = temp_dir.path().join("frontend"); + let runtime_root = temp_dir.path().join("backend"); + let profile_root = temp_dir.path().join("shared"); + for root in [&cwd, &runtime_root, &profile_root] { + std::fs::create_dir_all(root.join(".git"))?; + std::fs::create_dir_all(root.join(".codex"))?; + } + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([( + profile_root.to_string_lossy().into_owned(), + true, + )]), + }), + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":workspace_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + (".git".to_string(), FileSystemAccessMode::Read), + (".codex".to_string(), FileSystemAccessMode::Read), + ])), + )]), + }), + network: None, }, - ] - ) - ) + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.clone()), + additional_writable_roots: vec![runtime_root.clone()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + for root in [cwd.abs(), runtime_root.abs(), profile_root.abs()] { + assert!( + policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), + "expected workspace root to be writable, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()), + "expected .git carveout under {root:?}, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()), + "expected .codex carveout under {root:?}, policy: {policy:?}" + ); + } + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) ); Ok(()) } @@ -2071,7 +2178,7 @@ async fn default_permissions_can_select_builtin_full_access_profile() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); assert_eq!( @@ -2188,6 +2295,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2244,6 +2352,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2304,6 +2413,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2347,6 +2457,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2383,6 +2494,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: None, network: None, }) @@ -2411,6 +2523,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -2446,6 +2559,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2492,6 +2606,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -3087,13 +3202,15 @@ exclude_slash_tmp = true ); continue; } + assert_eq!( + config.permissions.workspace_roots(), + &[cwd.abs(), extra_root.clone()] + ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, + path: FileSystemPath::Path { path: cwd.abs() }, access: FileSystemAccessMode::Write, }) ); @@ -3112,15 +3229,16 @@ exclude_slash_tmp = true file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some( - subpath.into() - )), + path: FileSystemPath::Path { + path: AbsolutePathBuf::resolve_path_against_base( + subpath, + cwd.path() + ), }, access: FileSystemAccessMode::Read, }), - "case `{name}` should preserve `{subpath}` as a symbolic project-root \ - metadata carveout" + "case `{name}` should materialize `{subpath}` for the runtime workspace \ + root" ); } } @@ -7423,10 +7541,13 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any( + PermissionProfile::read_only() + ), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7438,6 +7559,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7870,10 +7993,11 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7885,6 +8009,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8031,10 +8157,11 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8046,6 +8173,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8177,10 +8306,11 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), + constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8192,6 +8322,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -9127,7 +9259,7 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); Ok(()) @@ -9155,7 +9287,7 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); assert_eq!(config.permissions.active_permission_profile(), None); @@ -9275,7 +9407,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ), WebSearchMode::Cached, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0587da83e2..7472e5c576 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -117,6 +116,7 @@ use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; +use crate::config::permissions::compile_permission_profile_workspace_roots; use crate::config::permissions::default_builtin_permission_profile_name; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_for_profile_selection; @@ -247,12 +247,16 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Canonical effective runtime permissions after config requirements and - /// runtime readable-root additions have been applied. - pub permission_profile: Constrained, + /// Canonical constrained permissions profile before runtime workspace-root + /// materialization has been applied. + constrained_permissions_profile: Constrained, /// Named or implicit built-in profile selected by config, rather than an /// ad-hoc override. - pub active_permission_profile: Option, + active_permission_profile: Option, + /// Thread-scoped runtime workspace roots. Symbolic `:workspace_roots` + /// entries in `constrained_permissions_profile` are materialized against + /// these roots. + workspace_roots: Vec, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -274,10 +278,67 @@ pub struct Permissions { } impl Permissions { + /// Build permissions from the constrained values required for a minimal + /// in-process configuration. + pub fn from_approval_and_profile( + approval_policy: Constrained, + permission_profile: Constrained, + ) -> Self { + Self { + approval_policy, + constrained_permissions_profile: permission_profile, + active_permission_profile: None, + workspace_roots: Vec::new(), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + } + } + + /// Borrow the constrained canonical profile. This preserves the raw + /// symbolic `:workspace_roots` form for session/thread state. + pub fn permission_profile(&self) -> &Constrained { + &self.constrained_permissions_profile + } + + /// Set the full constrained profile value and preserve the active profile + /// sidecar when the caller has already validated both together. + pub fn set_constrained_permission_profile_with_active_profile( + &mut self, + permission_profile: Constrained, + active_permission_profile: Option, + ) { + self.constrained_permissions_profile = permission_profile; + self.active_permission_profile = active_permission_profile; + } + + pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { + self.workspace_roots = workspace_roots; + } + + pub fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + /// Workspace roots that came from user-visible configuration or runtime + /// selection. Internal Codex-only writable roots are intentionally excluded. + pub fn user_visible_workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.constrained_permissions_profile + .get() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + } + /// Effective runtime permissions after config requirements and runtime - /// readable-root additions have been applied. - pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + /// workspace-root materialization have been applied. + pub fn effective_permission_profile(&self) -> PermissionProfile { + self.materialized_permission_profile() } /// Named profile selected by config, if the current profile has one. @@ -287,20 +348,23 @@ impl Permissions { /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } /// Effective network sandbox policy derived from the canonical profile. pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.constrained_permissions_profile + .get() + .network_sandbox_policy() } /// Legacy compatibility projection derived from the canonical profile. pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { - let permission_profile = self.permission_profile.get(); + let permission_profile = self.materialized_permission_profile(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &permission_profile, &file_system_sandbox_policy, permission_profile.network_sandbox_policy(), cwd, @@ -322,11 +386,12 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.can_set(&permission_profile) + self.constrained_permissions_profile + .can_set(&permission_profile) } - /// Replace permissions from a legacy sandbox policy and keep every - /// permission projection in sync. + /// Set permissions from a legacy sandbox policy and keep every permission + /// projection in sync. pub fn set_legacy_sandbox_policy( &mut self, sandbox_policy: SandboxPolicy, @@ -341,13 +406,34 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); + self.workspace_roots = match &sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + let mut workspace_roots = vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ]; + for root in writable_roots { + if !workspace_roots.iter().any(|existing| existing == root) { + workspace_roots.push(root.clone()); + } + } + workspace_roots + } + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } + | SandboxPolicy::ReadOnly { .. } => vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ], + }; - self.permission_profile.set(permission_profile)?; + self.constrained_permissions_profile + .set(permission_profile)?; self.active_permission_profile = None; Ok(()) } - /// Replace permissions from the canonical profile. + /// Set permissions from the canonical profile. pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, @@ -358,16 +444,15 @@ impl Permissions { ) } - /// Replace permissions from the canonical profile and record the named - /// source profile, if one is known. + /// Set permissions from the canonical profile and record the named source + /// profile, if one is known. pub fn set_permission_profile_with_active_profile( &mut self, permission_profile: PermissionProfile, active_permission_profile: Option, ) -> ConstraintResult<()> { - self.permission_profile.can_set(&permission_profile)?; - - self.permission_profile.set(permission_profile)?; + self.constrained_permissions_profile + .set(permission_profile)?; self.active_permission_profile = active_permission_profile; Ok(()) } @@ -577,6 +662,15 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute runtime workspace roots for the session. Symbolic + /// `:workspace_roots` permission entries are materialized against these + /// roots while profile-defined workspace roots remain encoded directly in + /// the permission profile. + pub workspace_roots: Vec, + /// Whether runtime workspace roots were supplied explicitly by the caller + /// or legacy config, rather than defaulting to `cwd`. + pub workspace_roots_explicit: bool, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1076,8 +1170,14 @@ impl Config { &mut self, sandbox_policy: SandboxPolicy, ) -> ConstraintResult<()> { + self.workspace_roots_explicit = matches!( + &sandbox_policy, + SandboxPolicy::WorkspaceWrite { writable_roots, .. } if !writable_roots.is_empty() + ); self.permissions - .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path()) + .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path())?; + self.workspace_roots = self.permissions.workspace_roots().to_vec(); + Ok(()) } pub fn to_models_manager_config(&self) -> ModelsManagerConfig { @@ -1926,6 +2026,14 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit runtime workspace roots for this session. When set, this is + /// the full runtime root list rather than an additive override. + pub workspace_roots: Option>, +} + +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2239,6 +2347,7 @@ impl Config { ephemeral, bypass_hook_trust, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); @@ -2329,11 +2438,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2375,12 +2483,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2390,6 +2493,40 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let legacy_workspace_roots_explicit = should_seed_legacy_workspace_roots + && cfg + .sandbox_workspace_write + .as_ref() + .is_some_and(|sandbox_workspace_write| { + !sandbox_workspace_write.writable_roots.is_empty() + }); + let workspace_roots_explicit = workspace_roots_override.is_some() + || !requested_additional_writable_roots.is_empty() + || legacy_workspace_roots_explicit; + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, @@ -2418,18 +2555,24 @@ impl Config { } else { NetworkProxyConfig::default() }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2463,6 +2606,22 @@ impl Config { resolved_cwd.as_path(), &mut startup_warnings, )?; + let mut configured_workspace_roots = compile_permission_profile_workspace_roots( + cfg.permissions.as_ref(), + default_permissions, + resolved_cwd.as_path(), + )?; + if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + dedupe_absolute_paths(&mut configured_workspace_roots); + workspace_roots.extend(configured_workspace_roots.iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + file_system_sandbox_policy = file_system_sandbox_policy + .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); let mut permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { @@ -2473,36 +2632,26 @@ impl Config { network_sandbox_policy, ) }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); - permission_profile = PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); @@ -2518,22 +2667,7 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, @@ -2572,25 +2706,21 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + let materialized_file_system_sandbox_policy = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:workspace_roots` - // entries. + // Keep Codex runtime write access while storing the runtime + // workspace roots separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -3105,11 +3235,14 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots: workspace_roots.clone(), + workspace_roots_explicit, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - permission_profile: constrained_permission_profile.value, + constrained_permissions_profile: constrained_permission_profile.value, active_permission_profile, + workspace_roots, network, allow_login_shell, shell_environment_policy, @@ -3393,7 +3526,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile().get(), PermissionProfile::Disabled ) && self .config_layer_stack diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b93b8745d7..9f8fcd9ee3 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; use codex_features::NetworkProxyDomainPermissionToml; @@ -33,6 +34,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; use codex_utils_absolute_path::AbsolutePathBuf; use super::ProjectConfig; @@ -72,12 +74,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { @@ -303,6 +305,41 @@ pub(crate) fn compile_permission_profile_selection( compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) } +pub(crate) fn compile_permission_profile_workspace_roots( + permissions: Option<&PermissionsToml>, + profile_name: &str, + policy_cwd: &Path, +) -> io::Result> { + if is_builtin_permission_profile_name(profile_name) { + return Ok(Vec::new()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let profile = resolve_permission_profile(permissions, profile_name)?; + Ok(compile_workspace_roots( + profile.workspace_roots.as_ref(), + policy_cwd, + )) +} + +fn compile_workspace_roots( + workspace_roots: Option<&WorkspaceRootsToml>, + policy_cwd: &Path, +) -> Vec { + workspace_roots.map_or_else(Vec::new, |workspace_roots| { + workspace_roots + .enabled_roots() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, policy_cwd)) + .collect() + }) +} + fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( @@ -478,7 +515,7 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - policy_cwd: &Path, + _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the @@ -493,15 +530,10 @@ fn compile_scoped_filesystem_pattern( match parse_special_path(path) { Some(FileSystemSpecialPath::ProjectRoots { .. }) => { - // `:workspace_roots` is represented as a special path, but current - // filesystem-policy resolution defines it relative to the session - // cwd. Use the same policy cwd here so glob entries and exact - // scoped entries resolve consistently. - Ok( - AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd) - .to_string_lossy() - .to_string(), - ) + // Keep `:workspace_roots` glob patterns symbolic until the active + // workspace roots are known, then materialize them for cwd and any + // runtime/profile-added workspace roots together. + Ok(project_roots_glob_pattern(&subpath)) } Some(_) => Err(io::Error::new( io::ErrorKind::InvalidInput, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 86a3c604dd..51cf13912e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -11,6 +11,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -66,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -275,6 +277,39 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { ); } +#[test] +fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std::io::Result<()> { + let cwd = TempDir::new()?; + let workspace_roots = compile_permission_profile_workspace_roots( + Some(&PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("backend".to_string(), true), + ("disabled".to_string(), false), + ]), + }), + filesystem: None, + network: None, + }, + )]), + }), + "workspace", + cwd.path(), + )?; + + assert_eq!( + workspace_roots, + vec![AbsolutePathBuf::resolve_path_against_base( + "backend", + cwd.path() + )] + ); + Ok(()) +} + #[test] fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() { let filesystem = FilesystemPermissionsToml { @@ -359,6 +394,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a7..cd5a8ac338 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -251,10 +251,11 @@ fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> String { } fn writable_roots_text(writable_roots: Option>) -> Option { - let roots = writable_roots?; + let mut roots = writable_roots?; if roots.is_empty() { return None; } + roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path())); let roots_list: Vec = roots .iter() diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 253ead5b41..25b84420dc 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -9,7 +9,6 @@ use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; @@ -894,9 +893,6 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); let sandbox_policy = SandboxPolicy::new_read_only_policy(); - guardian_config.permissions.permission_profile = Constrained::allow_only( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); guardian_config .permissions .set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path()) @@ -922,7 +918,7 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.permissions.network = Some(NetworkProxySpec::from_config_and_constraints( live_network_config, network_constraints, - guardian_config.permissions.permission_profile.get(), + guardian_config.permissions.permission_profile().get(), )?); } for feature in [ diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 7601ef44c3..18400fa230 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2163,7 +2163,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { }), ..Default::default() }), - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile().get(), ) .expect("network proxy spec"); parent_config.permissions.network = Some(network.clone()); @@ -2190,8 +2190,8 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { Constrained::allow_only(AskForApproval::Never) ); assert_eq!( - guardian_config.permissions.permission_profile, - Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( + guardian_config.permissions.permission_profile(), + &Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::new_read_only_policy(), )) ); @@ -2230,7 +2230,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( parent_network, /*requirements*/ None, - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile().get(), ) .expect("parent network proxy spec"), ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0a29e97883..5db9474721 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -617,7 +617,7 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: session_permission_profile_from_config(&config)?, active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -818,6 +818,20 @@ fn get_service_tier( .then_some(ServiceTier::Fast.request_value().to_string()) } +fn session_permission_profile_from_config( + config: &Config, +) -> CodexResult> { + let mut session_permission_profile = config.permissions.permission_profile().clone(); + session_permission_profile + .set(config.permissions.effective_permission_profile()) + .map_err(|err| { + CodexErr::Fatal(format!( + "failed to materialize workspace roots for session permissions: {err}" + )) + })?; + Ok(session_permission_profile) +} + fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { plan_type == AccountPlanType::Enterprise || plan_type.is_business_like() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index f7c7a91ca1..a15fbedc0d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -756,7 +756,7 @@ impl Session { let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( spec, current_exec_policy.as_ref(), - config.permissions.permission_profile.get(), + config.permissions.permission_profile().get(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, @@ -820,7 +820,7 @@ impl Session { // setup is straightforward enough and performs well. mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 38988329e9..50632ce81a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -36,6 +36,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -2128,9 +2129,12 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> }; let expected_sandbox_policy = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { - config.permissions.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); + config + .permissions + .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy, + )) + .expect("set permission profile"); config .set_legacy_sandbox_policy(sandbox_policy) .expect("set sandbox policy"); @@ -2149,6 +2153,33 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_permission_profile_materializes_runtime_workspace_roots() -> anyhow::Result<()> { + let codex_home = tempfile::TempDir::new()?; + let cwd = tempfile::TempDir::new()?; + let extra_root = tempfile::TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(crate::config::ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], + ..Default::default() + }) + .build() + .await?; + let session_permission_profile = session_permission_profile_from_config(&config)?; + let file_system_policy = session_permission_profile + .get() + .file_system_sandbox_policy(); + + assert!( + file_system_policy.can_write_path_with_cwd(extra_root.path(), config.cwd.as_path()), + "session permission profile should carry materialized runtime workspace roots" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -2884,7 +2915,7 @@ async fn set_rate_limits_retains_previous_credits() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -2988,7 +3019,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3461,7 +3492,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -3994,7 +4025,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4103,7 +4134,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4150,7 +4181,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( @@ -4335,7 +4366,7 @@ async fn make_session_with_config_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -4438,7 +4469,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -5955,7 +5986,7 @@ where compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), + permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), @@ -6002,7 +6033,7 @@ where let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, - &config.permissions.permission_profile, + config.permissions.permission_profile(), ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c646b5833d..45dc06c776 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -438,8 +438,12 @@ impl Session { per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; - per_turn_config.permissions.permission_profile = - session_configuration.permission_profile.clone(); + per_turn_config + .permissions + .set_constrained_permission_profile_with_active_profile( + session_configuration.permission_profile.clone(), + session_configuration.active_permission_profile.clone(), + ); let permission_profile = session_configuration.permission_profile(); let resolved_web_search_mode = resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); @@ -466,8 +470,6 @@ impl Session { Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.permissions.active_permission_profile = - session_configuration.active_permission_profile.clone(); config } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 12c25aa810..4fc6db14e4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2111,7 +2111,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { turn.permission_profile = expected_permission_profile.clone(); assert_ne!( expected_permission_profile, - turn.config.permissions.permission_profile(), + turn.config.permissions.effective_permission_profile(), "test requires a runtime profile override that differs from base config" ); @@ -3948,7 +3948,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( - &turn.config.permissions.permission_profile, + turn.config.permissions.permission_profile(), turn.config.legacy_sandbox_policy(), turn_cwd.as_path(), ); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 4d6259a599..05fbaad89f 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -542,8 +542,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .await; let writable = TempDir::new()?; let writable_root = AbsolutePathBuf::try_from(writable.path())?; + let writable_root_for_config = writable_root.clone(); let permission_profile = PermissionProfile::workspace_write_with( - &[writable_root], + std::slice::from_ref(&writable_root), NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, @@ -555,6 +556,8 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .permissions .set_permission_profile(permission_profile) .expect("test permission profile should be allowed"); + let workspace_roots = vec![config.cwd.clone(), writable_root_for_config]; + config.permissions.set_workspace_roots(workspace_roots); config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index eeccd5b465..8687b33ff0 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -905,9 +905,12 @@ allow_local_binding = true .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); - config.permissions.permission_profile = Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy_for_config), - ); + config + .permissions + .set_permission_profile(PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy_for_config, + )) + .expect("set permission profile"); }); let test = builder.build_with_remote_env(server).await?; assert!( @@ -2720,7 +2723,6 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { - use codex_config::Constrained; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -2751,11 +2753,13 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { }, access: FileSystemAccessMode::None, }); - config.permissions.permission_profile = - Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let TestCodex { codex, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 1285b9f925..4fe3586d7f 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -344,11 +344,13 @@ async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Re let server = responses::start_mock_server().await; let mut builder = core_test_support::test_codex::test_codex().with_config(|config| { let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); - config.permissions.permission_profile = - codex_config::Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 546e4e44fb..37a577e2c8 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -39,6 +39,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-oss = { workspace = true } +codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 92248c19ec..755d754f08 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,5 +1,4 @@ use std::io::IsTerminal; -use std::path::Path; use std::path::PathBuf; use codex_app_server_protocol::CommandExecutionStatus; @@ -11,11 +10,9 @@ use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_model_provider_info::WireApi; -use codex_protocol::models::PermissionProfile; use codex_protocol::num_format::format_with_separators; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_utils_absolute_path::canonicalize_preserving_symlinks; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::OwoColorize; use owo_colors::Style; @@ -437,8 +434,9 @@ fn config_summary_entries( ( "sandbox", summarize_permission_profile( - config.permissions.permission_profile.get(), - config.cwd.as_path(), + &config.permissions.effective_permission_profile(), + &config.cwd, + config.permissions.user_visible_workspace_roots(), ), ), ]; @@ -465,83 +463,6 @@ fn config_summary_entries( entries } -fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: &Path) -> String { - match permission_profile { - PermissionProfile::Disabled => "danger-full-access".to_string(), - PermissionProfile::External { network } => { - let mut summary = "external-sandbox".to_string(); - append_network_summary(&mut summary, *network); - summary - } - PermissionProfile::Managed { .. } => { - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let network_policy = permission_profile.network_sandbox_policy(); - if file_system_policy.has_full_disk_write_access() { - let mut summary = "workspace-write [/]".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); - if writable_roots.is_empty() { - let mut summary = "read-only".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let mut summary = "workspace-write".to_string(); - let writable_entries = writable_roots - .iter() - .map(|root| writable_root_label(root.root.as_path(), cwd)) - .collect::>(); - summary.push_str(&format!(" [{}]", writable_entries.join(", "))); - append_network_summary(&mut summary, network_policy); - summary - } - } -} - -fn append_network_summary(summary: &mut String, network_policy: NetworkSandboxPolicy) { - if network_policy.is_enabled() { - summary.push_str(" (network access enabled)"); - } -} - -fn writable_root_label(root: &Path, cwd: &Path) -> String { - if paths_match_after_canonicalization(root, cwd) { - return "workdir".to_string(); - } - if paths_match_after_canonicalization(root, Path::new("/tmp")) { - return "/tmp".to_string(); - } - if std::env::var_os("TMPDIR") - .filter(|tmpdir| !tmpdir.is_empty()) - .is_some_and(|tmpdir| paths_match_after_canonicalization(root, Path::new(&tmpdir))) - { - return "$TMPDIR".to_string(); - } - display_path_label(root) -} - -fn paths_match_after_canonicalization(left: &Path, right: &Path) -> bool { - match ( - canonicalize_preserving_symlinks(left), - canonicalize_preserving_symlinks(right), - ) { - (Ok(left), Ok(right)) if left == right => true, - _ => display_path_label(left) == display_path_label(right), - } -} - -fn display_path_label(path: &Path) -> String { - path.strip_prefix("/private/tmp") - .ok() - .map(|suffix| Path::new("/tmp").join(suffix)) - .unwrap_or_else(|| path.to_path_buf()) - .to_string_lossy() - .to_string() -} - fn reasoning_text( summary: &[String], content: &[String], diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 479758f9a0..17cfda4550 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -10,16 +10,15 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::Style; use pretty_assertions::assert_eq; use super::EventProcessorWithHumanOutput; use super::final_message_from_turn_items; -use super::paths_match_after_canonicalization; use super::reasoning_text; use super::should_print_final_message_to_stdout; use super::should_print_final_message_to_tty; -use super::summarize_permission_profile; use crate::event_processor::EventProcessor; #[test] @@ -101,10 +100,13 @@ fn reasoning_text_uses_raw_content_when_enabled() { #[test] fn summarizes_disabled_permission_profile_as_danger_full_access() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::Disabled, - test_path_buf("/tmp").as_path() + &cwd, + std::slice::from_ref(&cwd), ), "danger-full-access" ); @@ -112,12 +114,15 @@ fn summarizes_disabled_permission_profile_as_danger_full_access() { #[test] fn summarizes_external_permission_profile() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::External { network: NetworkSandboxPolicy::Enabled, }, - test_path_buf("/tmp").as_path(), + &cwd, + std::slice::from_ref(&cwd), ), "external-sandbox (network access enabled)" ); @@ -144,32 +149,25 @@ fn summarizes_managed_workspace_write_permission_profile() { ); assert_eq!( - summarize_permission_profile(&profile, cwd.as_path()), + summarize_permission_profile(&profile, &cwd, &[cwd.clone(), cache_root.clone()]), format!("workspace-write [workdir, {}]", cache_root.display()) ); } #[test] fn summarizes_managed_read_only_permission_profile() { + let cwd = test_path_buf("/tmp/project").abs(); let profile = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ); assert_eq!( - summarize_permission_profile(&profile, test_path_buf("/tmp/project").as_path()), + summarize_permission_profile(&profile, &cwd, std::slice::from_ref(&cwd)), "read-only" ); } -#[test] -fn distinct_missing_paths_do_not_match_after_canonicalization() { - assert!(!paths_match_after_canonicalization( - test_path_buf("/tmp/codex-missing-left").as_path(), - test_path_buf("/tmp/codex-missing-right").as_path(), - )); -} - #[test] fn final_message_from_turn_items_uses_latest_agent_message() { let message = final_message_from_turn_items(&[ diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2485a97524..b4724edd29 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -83,7 +83,6 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; @@ -419,6 +418,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result permission_profile: None, default_permissions: None, cwd: resolved_cwd, + workspace_roots: None, model_provider: model_provider.clone(), service_tier: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), @@ -760,7 +760,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) { event_processor.process_warning(message); } @@ -953,7 +953,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -975,7 +975,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -997,20 +997,25 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) + let modifications = workspace_roots + .iter() + .filter(|root| root.as_path() != cwd) + .cloned() + .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) .collect::>(); PermissionProfileSelectionParams::Profile { id: active.id, @@ -1091,7 +1096,7 @@ fn session_configured_from_thread_start_response( .permission_profile .clone() .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + .unwrap_or_else(|| config.permissions.effective_permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, @@ -1116,7 +1121,7 @@ fn session_configured_from_thread_resume_response( .permission_profile .clone() .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + .unwrap_or_else(|| config.permissions.effective_permission_profile()), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 321f4b8f9e..2aa2663435 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -1,6 +1,8 @@ use super::*; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use opentelemetry::trace::TraceContextExt; @@ -456,6 +458,28 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } +#[test] +fn active_profile_selection_includes_extra_workspace_roots_as_modifications() { + let cwd = test_path_buf("/workspace/project").abs(); + let extra_root = test_path_buf("/workspace/cache").abs(); + + let selection = permissions_selection_from_active_profile( + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + cwd.as_path(), + &[cwd.clone(), extra_root.clone()], + ); + + assert_eq!( + selection, + PermissionProfileSelectionParams::Profile { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + modifications: Some(vec![ + PermissionProfileModificationParams::AdditionalWritableRoot { path: extra_root } + ]), + } + ); +} + #[tokio::test] async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { let codex_home = tempdir().expect("create temp codex home"); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6919ee43e7..512dbd5444 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -344,21 +344,6 @@ pub struct ActivePermissionProfile { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option, - - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub modifications: Vec, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "snake_case")] - #[ts(rename_all = "snake_case")] - AdditionalWritableRoot { path: AbsolutePathBuf }, } impl ActivePermissionProfile { @@ -366,17 +351,8 @@ impl ActivePermissionProfile { Self { id: id.into(), extends: None, - modifications: Vec::new(), } } - - pub fn with_modifications( - mut self, - modifications: Vec, - ) -> Self { - self.modifications = modifications; - self - } } impl Default for PermissionProfile { @@ -444,6 +420,28 @@ impl PermissionProfile { } } + pub fn materialize_project_roots_with_workspace_roots( + self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + match self { + Self::Managed { + file_system, + network, + } => { + let file_system = file_system + .to_sandbox_policy() + .materialize_project_roots_with_workspace_roots(workspace_roots); + Self::Managed { + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), + network, + } + } + Self::Disabled => Self::Disabled, + Self::External { network } => Self::External { network }, + } + } + pub fn from_runtime_permissions( file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index e6d9503ea1..1d8a1e707e 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -350,6 +350,12 @@ pub enum FileSystemPath { }, } +const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://"; + +pub fn project_roots_glob_pattern(subpath: &Path) -> String { + format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { Self { @@ -703,15 +709,100 @@ impl FileSystemSandboxPolicy { pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self { let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok(); for entry in &mut self.entries { - let FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { .. }, - } = &entry.path - else { - continue; - }; + match &entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => { + if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { + entry.path = FileSystemPath::Path { path }; + } + } + FileSystemPath::GlobPattern { pattern } => { + if let (Some(cwd), Some(subpath)) = + (cwd.as_ref(), parse_project_roots_glob_pattern(pattern)) + { + entry.path = FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, cwd), + }; + } + } + FileSystemPath::Special { value: _ } => {} + FileSystemPath::Path { .. } => {} + } + } + self + } - if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { - entry.path = FileSystemPath::Path { path }; + /// Replaces symbolic `:workspace_roots` entries with concrete entries for + /// each workspace root. + pub fn materialize_project_roots_with_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + match entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath }, + } => { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: match subpath.as_ref() { + Some(subpath) => AbsolutePathBuf::resolve_path_against_base( + subpath, + root.as_path(), + ), + None => root.clone(), + }, + }, + access: entry.access, + })); + } + FileSystemPath::GlobPattern { pattern } => { + if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, root), + }, + access: entry.access, + })); + } else { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { pattern }, + access: entry.access, + }); + } + } + FileSystemPath::Path { path } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: entry.access, + }); + } + FileSystemPath::Special { value } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { value }, + access: entry.access, + }); + } + } + } + self.entries = entries; + self + } + + /// Preserves symbolic `:workspace_roots` entries while also adding concrete + /// entries for each provided workspace root. + pub fn with_materialized_project_roots_for_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let materialized = self + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); + for entry in materialized.entries { + if !self.entries.contains(&entry) { + self.entries.push(entry); } } self @@ -1209,6 +1300,18 @@ fn resolve_entry_path( } } +fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> { + pattern + .strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX) + .map(Path::new) +} + +fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String { + AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path()) + .to_string_lossy() + .into_owned() +} + fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { if path.is_absolute() { AbsolutePathBuf::from_absolute_path(path).ok() @@ -2750,6 +2853,115 @@ mod tests { ); } + #[test] + fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() { + let temp_dir = TempDir::new().expect("tempdir"); + let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first")) + .expect("resolve first root"); + let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second")) + .expect("resolve second root"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(".git".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let actual = + policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + first.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + second.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + ]) + ); + } + + #[test] + fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() { + let cwd = TempDir::new().expect("tempdir"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }]); + + let actual = policy.materialize_project_roots_with_cwd(cwd.path()); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }]) + ); + } + #[test] fn with_additional_legacy_workspace_writable_roots_protects_metadata() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 4dde737922..b8b7b0a519 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -41,7 +41,6 @@ use codex_core_api::RealtimeAudioConfig; use codex_core_api::RealtimeConfig; use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; -use codex_core_api::ShellEnvironmentPolicy; use codex_core_api::TerminalResizeReflowConfig; use codex_core_api::ThreadManager; use codex_core_api::ThreadStoreConfig; @@ -172,16 +171,10 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_provider_id, model_provider, personality: None, - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: None, - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, + permissions: Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_any(PermissionProfile::read_only()), + ), approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, @@ -213,7 +206,9 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, - cwd, + cwd: cwd.clone(), + workspace_roots: vec![cwd], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 77154d3e5a..1f160fa3c3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -957,7 +957,7 @@ See the Codex keymap documentation for supported actions and examples." // world-writable dirs on Windows. #[cfg(target_os = "windows")] { - let startup_permission_profile = app.config.permissions.permission_profile(); + let startup_permission_profile = app.config.permissions.effective_permission_profile(); let should_check = WindowsSandboxLevel::from_config(&app.config) != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 26cf16a9e0..807663189d 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -299,10 +299,13 @@ impl App { self.config.permissions.approval_policy.value(), )); } - if permission_profile_override.is_some() + let permission_profile_override_value = permission_profile_override + .is_some() + .then(|| self.config.permissions.permission_profile().get().clone()); + if let Some(permission_profile) = permission_profile_override_value.as_ref() && let Err(err) = self .chat_widget - .set_permission_profile(self.config.permissions.permission_profile()) + .set_permission_profile(permission_profile.clone()) { tracing::error!( error = %err, @@ -311,9 +314,8 @@ impl App { self.chat_widget .add_error_message(format!("Failed to enable Auto-review: {err}")); } - if permission_profile_override.is_some() { - self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + if let Some(permission_profile) = permission_profile_override_value { + self.runtime_permission_profile_override = Some(permission_profile); } if approval_policy_override.is_some() diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bff4479f06..fb351991b6 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1426,7 +1426,7 @@ impl App { return Ok(AppRunControl::Continue); } self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + Some(self.config.permissions.permission_profile().get().clone()); self.sync_active_thread_permission_settings_to_cached_session() .await; @@ -1450,7 +1450,8 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - let permission_profile = self.config.permissions.permission_profile(); + let permission_profile = + self.config.permissions.effective_permission_profile(); Self::spawn_world_writable_scan( cwd, env_map, diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index c04c16fdc8..e2b3477756 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -67,7 +67,7 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { let Some(message) = - codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile().get()) else { return; }; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2abecc3585..abdde1c995 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1639,8 +1639,9 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< app.chat_widget .config_ref() .permissions - .permission_profile(), - auto_review.permission_profile + .permission_profile() + .get(), + &auto_review.permission_profile ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, @@ -1816,8 +1817,9 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review app.chat_widget .config_ref() .permissions - .permission_profile(), - auto_review.permission_profile + .permission_profile() + .get(), + &auto_review.permission_profile ); assert_eq!( op_rx.try_recv(), @@ -3001,7 +3003,9 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); assert_eq!( session.permission_profile, expected_permission_profile, "thread/read does not return fresh server permissions; the fallback profile must use the \ @@ -3136,7 +3140,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { fork_config.model_reasoning_effort, fork_config.service_tier.as_deref(), fork_config.permissions.approval_policy.value(), - fork_config.permissions.permission_profile(), + fork_config.permissions.permission_profile().get(), fork_config.approvals_reviewer, ), ( @@ -3144,7 +3148,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { Some(ReasoningEffortConfig::High), Some(parent_service_tier), AskForApproval::OnRequest.to_core(), - parent_permission_profile, + &parent_permission_profile, ApprovalsReviewer::AutoReview, ) ); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f25398b0b8..9d6cda012d 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -589,7 +589,9 @@ impl App { let approvals_reviewer = approvals_reviewer.unwrap_or(config.approvals_reviewer); let active_permission_profile = - if config.permissions.permission_profile() == permission_profile.clone() { + if config.permissions.effective_permission_profile() + == permission_profile.clone() + { config.permissions.active_permission_profile() } else { None @@ -603,6 +605,7 @@ impl App { approvals_reviewer, permission_profile.clone(), active_permission_profile, + config.permissions.user_visible_workspace_roots(), model.to_string(), *effort, *summary, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4f5f48cfc8..d057baa40c 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -19,7 +19,9 @@ impl App { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); let active_permission_profile = self .chat_widget .config_ref() @@ -101,6 +103,8 @@ impl App { .config_ref() .permissions .permission_profile() + .get() + .clone() } fn current_active_permission_profile(&self) -> Option { @@ -350,11 +354,13 @@ mod tests { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .get() + .clone(); assert_eq!(session.permission_profile, expected_permission_profile); assert_ne!( session.permission_profile, - app.config.permissions.permission_profile(), + app.config.permissions.permission_profile().get().clone(), "thread/read fallback must use the active widget permissions rather than stale app \ config defaults" ); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56ad0ccdea..6a0af955ba 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -108,7 +108,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -552,6 +551,7 @@ impl AppServerSession { approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, active_permission_profile: Option, + workspace_roots: &[AbsolutePathBuf], model: String, effort: Option, summary: Option, @@ -565,6 +565,7 @@ impl AppServerSession { &permission_profile, active_permission_profile, cwd.as_path(), + workspace_roots, self.thread_params_mode(), ); self.client @@ -1174,15 +1175,14 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, + cwd: &std::path::Path, + workspace_roots: &[AbsolutePathBuf], ) -> PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) + let modifications = workspace_roots + .iter() + .filter(|root| root.as_path() != cwd) + .cloned() + .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) .collect::>(); PermissionProfileSelectionParams::Profile { id: active.id, @@ -1194,13 +1194,15 @@ fn turn_permissions_overrides( permission_profile: &PermissionProfile, active_permission_profile: Option, cwd: &std::path::Path, + workspace_roots: &[AbsolutePathBuf], thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile.map(permissions_selection_from_active_profile) + active_permission_profile + .map(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots)) } else { None }; @@ -1229,7 +1231,13 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(|active| { + permissions_selection_from_active_profile( + active, + config.cwd.as_path(), + config.permissions.user_visible_workspace_roots(), + ) + }) } fn thread_start_params_from_config( @@ -1243,7 +1251,7 @@ fn thread_start_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1277,7 +1285,7 @@ fn thread_resume_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1309,7 +1317,7 @@ fn thread_fork_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1499,7 +1507,7 @@ fn permission_profile_from_thread_response( return permission_profile.clone().into(); } match thread_params_mode { - ThreadParamsMode::Embedded => config.permissions.permission_profile(), + ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(), ThreadParamsMode::Remote => { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } @@ -1635,7 +1643,13 @@ mod tests { config .permissions .active_permission_profile() - .map(permissions_selection_from_active_profile) + .map(|active| { + permissions_selection_from_active_profile( + active, + config.cwd.as_path(), + config.permissions.user_visible_workspace_roots(), + ) + }) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1661,13 +1675,18 @@ mod tests { let cwd = test_path_buf("/workspace/project").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let expected_permissions = - permissions_selection_from_active_profile(active_permission_profile.clone()); + let workspace_roots = vec![cwd.clone()]; + let expected_permissions = permissions_selection_from_active_profile( + active_permission_profile.clone(), + cwd.as_path(), + &workspace_roots, + ); let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), Some(active_permission_profile), cwd.as_path(), + &workspace_roots, ThreadParamsMode::Embedded, ); @@ -1675,6 +1694,36 @@ mod tests { assert_eq!(permissions, Some(expected_permissions)); } + #[test] + fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() { + let cwd = test_path_buf("/workspace/project").abs(); + let extra_root = test_path_buf("/workspace/cache").abs(); + let active_permission_profile = + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); + let workspace_roots = vec![cwd.clone(), extra_root.clone()]; + + let (sandbox_policy, permissions) = turn_permissions_overrides( + &PermissionProfile::workspace_write(), + Some(active_permission_profile), + cwd.as_path(), + &workspace_roots, + ThreadParamsMode::Embedded, + ); + + assert_eq!(sandbox_policy, None); + assert_eq!( + permissions, + Some(PermissionProfileSelectionParams::Profile { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + modifications: Some(vec![ + PermissionProfileModificationParams::AdditionalWritableRoot { + path: extra_root + } + ]), + }) + ); + } + #[test] fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { let cwd = test_path_buf("/workspace/project").abs(); @@ -1683,6 +1732,7 @@ mod tests { &PermissionProfile::read_only(), /*active_permission_profile*/ None, cwd.as_path(), + std::slice::from_ref(&cwd), ThreadParamsMode::Embedded, ); @@ -1705,6 +1755,7 @@ mod tests { BUILT_IN_PERMISSION_PROFILE_READ_ONLY, )), cwd.as_path(), + std::slice::from_ref(&cwd), ThreadParamsMode::Remote, ); @@ -1723,7 +1774,7 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); @@ -1830,7 +1881,7 @@ mod tests { let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 9043cb3d9d..6e9860bb77 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -334,7 +334,7 @@ impl ChatWidget { None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, }; - let permission_profile = self.config.permissions.permission_profile(); + let permission_profile = self.config.permissions.effective_permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index dc428b4092..cf341cb829 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -17,7 +17,7 @@ impl ChatWidget { let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile(); + let current_permission_profile = self.config.permissions.permission_profile().get().clone(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 892e43f3ec..58cbe9bb73 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -32,7 +32,26 @@ impl ChatWidget { self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); + let previous_cwd = self.config.cwd.clone(); + let previous_workspace_roots = self.config.workspace_roots.clone(); self.config.cwd = session.cwd.clone(); + if !self.config.workspace_roots_explicit { + let mut workspace_roots = vec![session.cwd.clone()]; + if previous_workspace_roots + .iter() + .any(|root| root == &previous_cwd) + { + for root in previous_workspace_roots { + if root != previous_cwd + && !workspace_roots.iter().any(|existing| existing == &root) + { + workspace_roots.push(root); + } + } + } + self.config.workspace_roots = workspace_roots.clone(); + self.config.permissions.set_workspace_roots(workspace_roots); + } self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config @@ -53,10 +72,12 @@ impl ChatWidget { ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - self.config.permissions.permission_profile = - Constrained::allow_only(session.permission_profile.clone()); - self.config.permissions.active_permission_profile = - session.active_permission_profile.clone(); + self.config + .permissions + .set_constrained_permission_profile_with_active_profile( + Constrained::allow_only(session.permission_profile.clone()), + session.active_permission_profile.clone(), + ); } self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 12ed07cf98..8dd5f327d3 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -901,8 +901,12 @@ fn permissions_display(config: &Config) -> String { return active_permission_profile.id.clone(); } - let permission_profile = config.permissions.permission_profile(); - let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path()); + let permission_profile = config.permissions.effective_permission_profile(); + let summary = summarize_permission_profile( + &permission_profile, + &config.cwd, + config.permissions.workspace_roots(), + ); if let Some(details) = summary.strip_prefix("read-only") && !details.contains("(network access enabled)") { diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d77823c3cd..e6c8fcad88 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -282,7 +282,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + AppServerPermissionProfile::from( + chat.config_ref().permissions.effective_permission_profile() + ), expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); @@ -291,9 +293,65 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.set_permission_profile(updated_profile.clone()) .expect("set permission profile"); assert_eq!( - chat.config_ref().permissions.permission_profile(), - updated_profile, - "local permission changes should replace SessionConfigured profile-derived runtime permissions" + chat.config_ref().permissions.permission_profile().get(), + &updated_profile, + "local permission changes should replace SessionConfigured canonical permissions" + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + updated_profile + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)), + "effective permissions should still use the current thread runtime workspace roots" + ); +} + +#[tokio::test] +async fn session_configured_preserves_profile_workspace_roots() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + let previous_cwd = test_path_buf("/home/user/main").abs(); + let profile_root = test_path_buf("/home/user/shared").abs(); + chat.config.cwd = previous_cwd.clone(); + chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()]; + chat.config.workspace_roots_explicit = false; + chat.config + .permissions + .set_workspace_roots(chat.config.workspace_roots.clone()); + + let session_cwd = test_path_buf("/home/user/sub-agent").abs(); + let session_workspace_roots = vec![session_cwd.clone(), profile_root]; + let session_permission_profile = PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(&session_workspace_roots); + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: session_permission_profile.clone(), + active_permission_profile: None, + cwd: session_cwd.clone(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + message_history: None, + network_proxy: None, + rollout_path: None, + }; + + chat.handle_thread_session(configured); + + assert_eq!(&chat.config_ref().cwd, &session_cwd); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + session_workspace_roots.as_slice() + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + session_permission_profile ); } @@ -334,7 +392,9 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + AppServerPermissionProfile::from( + chat.config_ref().permissions.effective_permission_profile() + ), expected_app_server_permission_profile ); } diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 3f6de0879f..401e2a787d 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -15,14 +15,7 @@ impl ChatWidget { } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); - let Ok(policy) = self - .config - .permissions - .permission_profile() - .to_legacy_sandbox_policy(self.config.cwd.as_path()) - else { - return Some((Vec::new(), 0, true)); - }; + let policy = self.config.legacy_sandbox_policy(); match codex_windows_sandbox::apply_world_writable_scan_and_denies( self.config.codex_home.as_path(), cwd.as_path(), @@ -72,7 +65,9 @@ impl ChatWidget { let mode_label = preset .as_ref() .map(|p| describe_profile(&p.permission_profile)) - .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); + .unwrap_or_else(|| { + describe_profile(&self.config.permissions.effective_permission_profile()) + }); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7b97151edb..d78d3f86b0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1683,7 +1683,7 @@ pub(crate) fn new_session_info( pub(crate) fn is_yolo_mode(config: &Config) -> bool { has_yolo_permissions( AskForApproval::from(config.permissions.approval_policy.value()), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 47ff11d374..c3ec776206 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1047,7 +1047,7 @@ pub async fn run_main( if let Some(warning) = add_dir_warning_message( &cli.add_dir, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) { #[allow(clippy::print_stderr)] diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 0e19a15821..9a7c23606f 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -15,17 +15,16 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sandbox_summary::summarize_permission_profile; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; -use std::path::Path; use std::path::PathBuf; use url::Url; @@ -256,7 +255,8 @@ impl StatusHistoryCell { refreshing_rate_limits: bool, ) -> (Self, StatusHistoryHandle) { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); - let permission_profile = config.permissions.permission_profile(); + let permission_profile = config.permissions.effective_permission_profile(); + let workspace_roots = config.permissions.user_visible_workspace_roots(); let mut config_entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", model_name.to_string()), @@ -267,7 +267,7 @@ impl StatusHistoryCell { ), ( "sandbox", - summarize_permission_profile(&permission_profile, config.cwd.as_path()), + summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots), ), ]; if config.model_provider.wire_api == WireApi::Responses { @@ -291,7 +291,8 @@ impl StatusHistoryCell { .map(|(_, v)| v.clone()) .unwrap_or_else(|| "".to_string()); let active_permission_profile = config.permissions.active_permission_profile(); - let sandbox = status_permission_summary(&permission_profile, config.cwd.as_path()); + let sandbox = status_permission_summary(&permission_profile, &config.cwd, workspace_roots); + let workspace_root_suffix = workspace_root_suffix(workspace_roots, &config.cwd); let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval); let permissions = status_permissions_label( active_permission_profile.as_ref(), @@ -299,6 +300,7 @@ impl StatusHistoryCell { approval_policy, &sandbox, &approval, + workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); let account = compose_account_display(account_display); @@ -542,8 +544,12 @@ impl StatusHistoryCell { } } -fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) -> String { - let summary = summarize_permission_profile(permission_profile, cwd); +fn status_permission_summary( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], +) -> String { + let summary = summarize_permission_profile(permission_profile, cwd, workspace_roots); if let Some(details) = summary.strip_prefix("read-only") { if details.contains("(network access enabled)") { return "read-only with network access".to_string(); @@ -562,33 +568,31 @@ fn status_permission_summary(permission_profile: &PermissionProfile, cwd: &Path) summary } +fn workspace_root_suffix( + workspace_roots: &[AbsolutePathBuf], + cwd: &AbsolutePathBuf, +) -> Option { + let extra_roots = workspace_roots + .iter() + .filter(|root| *root != cwd) + .map(|root| root.to_string_lossy().to_string()) + .collect::>(); + if extra_roots.is_empty() { + None + } else { + Some(format!(" [{}]", extra_roots.join(", "))) + } +} + fn status_permissions_label( active_permission_profile: Option<&ActivePermissionProfile>, permission_profile: &PermissionProfile, approval_policy: AskForApproval, sandbox: &str, approval: &str, + workspace_root_suffix: Option<&str>, ) -> String { let active_id = active_permission_profile.map(|active| active.id.as_str()); - let writable_root_modifications = active_permission_profile - .map(|active| { - active - .modifications - .iter() - .filter(|modification| { - matches!( - modification, - ActivePermissionProfileModification::AdditionalWritableRoot { .. } - ) - }) - .count() - }) - .unwrap_or(0); - let modification_suffix = match writable_root_modifications { - 0 => String::new(), - 1 => " + 1 writable root".to_string(), - count => format!(" + {count} writable roots"), - }; match active_id { Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => { let label = if sandbox == "read-only with network access" { @@ -596,12 +600,20 @@ fn status_permissions_label( } else { "Read Only" }; - return format!("{label}{modification_suffix} ({approval})"); + return format!("{label} ({approval})"); } Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox { - "workspace" => return format!("Workspace{modification_suffix} ({approval})"), + "workspace" => { + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); + } "workspace with network access" => { - return format!("Workspace with network access{modification_suffix} ({approval})"); + return format!( + "Workspace with network access{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); } _ => {} }, @@ -614,7 +626,10 @@ fn status_permissions_label( format!("No Sandbox ({approval})") }; } - Some(id) => return format!("Profile {id}{modification_suffix} ({sandbox}, {approval})"), + Some(id) => { + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); + return format!("Profile {id} ({sandbox}, {approval})"); + } None => {} } @@ -622,16 +637,27 @@ fn status_permissions_label( return format!("Read Only ({approval})"); } if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" { - return format!("Workspace ({approval})"); + return format!( + "Workspace{} ({approval})", + workspace_root_suffix.unwrap_or("") + ); } if approval_policy == AskForApproval::Never && permission_profile == &PermissionProfile::Disabled { return "Full Access".to_string(); } + let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix); format!("Custom ({sandbox}, {approval})") } +fn decorate_workspace_sandbox_label(sandbox: &str, workspace_root_suffix: Option<&str>) -> String { + match workspace_root_suffix { + Some(suffix) if sandbox.starts_with("workspace") => format!("{sandbox}{suffix}"), + _ => sandbox.to_string(), + } +} + fn status_approval_label( approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index a1a63bc4b9..e69f2da3c8 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -31,12 +31,12 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::*; @@ -97,6 +97,14 @@ async fn test_config(temp_home: &TempDir) -> Config { config } +fn set_workspace_cwd(config: &mut Config, cwd: AbsolutePathBuf) { + config.cwd = cwd.clone(); + config.workspace_roots = vec![cwd]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); +} + fn test_status_account_display() -> Option { None } @@ -195,7 +203,7 @@ async fn status_snapshot_includes_reasoning_details() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile(PermissionProfile::workspace_write()) @@ -273,7 +281,7 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() { .approval_policy .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile(app_server_workspace_write_profile( @@ -332,20 +340,15 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() &file_system_policy, NetworkSandboxPolicy::Restricted, ), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) - .with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Read Only + 1 writable root (on-request)") + Some("Read Only (on-request)") ); } @@ -419,20 +422,44 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, ), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) - .with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, - }, - ]), - ), + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), ) .expect("set permission profile"); assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace + 1 writable root (on-request)") + Some("Workspace (on-request)") + ); +} + +#[tokio::test] +async fn status_permissions_workspace_roots_show_additional_directories() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest.to_core()) + .expect("set approval policy"); + let extra_root = test_path_buf("/workspace/extra").abs(); + config.workspace_roots = vec![config.cwd.clone(), extra_root.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config + .permissions + .set_permission_profile_with_active_profile( + PermissionProfile::workspace_write(), + Some(ActivePermissionProfile::new(":workspace")), + ) + .expect("set permission profile"); + + assert_eq!( + permissions_text_for(&config), + Some(format!("Workspace [{}] (on-request)", extra_root.display())) ); } @@ -489,7 +516,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions .set_permission_profile_with_active_profile( @@ -586,7 +613,7 @@ async fn status_snapshot_shows_auto_review_permissions() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config.approvals_reviewer = ApprovalsReviewer::AutoReview; config .permissions @@ -689,7 +716,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -743,7 +770,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1001,7 +1028,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1049,7 +1076,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1113,7 +1140,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1161,7 +1188,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1212,7 +1239,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let usage = TokenUsage { input_tokens: 500, @@ -1277,7 +1304,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1347,7 +1374,7 @@ async fn status_snapshot_shows_unavailable_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1405,7 +1432,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let usage = TokenUsage { input_tokens: 500, @@ -1463,7 +1490,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -1530,7 +1557,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = test_path_buf("/workspace/tests").abs(); + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); let account_display = test_status_account_display(); let usage = TokenUsage { diff --git a/codex-rs/utils/sandbox-summary/Cargo.toml b/codex-rs/utils/sandbox-summary/Cargo.toml index 758d779781..cb892238d9 100644 --- a/codex-rs/utils/sandbox-summary/Cargo.toml +++ b/codex-rs/utils/sandbox-summary/Cargo.toml @@ -11,6 +11,7 @@ workspace = true codex-core = { workspace = true } codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } [dev-dependencies] codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index 0719773aad..47e801bec4 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -1,7 +1,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::SandboxPolicy; -use std::path::Path; +use codex_utils_absolute_path::AbsolutePathBuf; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { @@ -51,8 +51,39 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { } } -pub fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: &Path) -> String { - match permission_profile.to_legacy_sandbox_policy(cwd) { +pub fn summarize_permission_profile( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, + workspace_roots: &[AbsolutePathBuf], +) -> String { + match permission_profile.to_legacy_sandbox_policy(cwd.as_path()) { + Ok(SandboxPolicy::WorkspaceWrite { + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + .. + }) => { + let mut summary = "workspace-write".to_string(); + let mut writable_entries = vec!["workdir".to_string()]; + if !exclude_slash_tmp { + writable_entries.push("/tmp".to_string()); + } + if !exclude_tmpdir_env_var { + writable_entries.push("$TMPDIR".to_string()); + } + writable_entries.extend( + workspace_roots + .iter() + .filter(|root| *root != cwd) + .map(|root| root.to_string_lossy().to_string()), + ); + + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + if network_access { + summary.push_str(" (network access enabled)"); + } + summary + } Ok(policy) => summarize_sandbox_policy(&policy), Err(_) => { if permission_profile.network_sandbox_policy().is_enabled() { @@ -67,6 +98,7 @@ pub fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: #[cfg(test)] mod tests { use super::*; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -112,4 +144,39 @@ mod tests { ) ); } + + #[test] + fn permission_profile_summary_uses_runtime_workspace_roots_and_hides_internal_writes() { + let cwd = + AbsolutePathBuf::try_from(if cfg!(windows) { "C:\\repo" } else { "/repo" }).unwrap(); + let extra_root = AbsolutePathBuf::try_from(if cfg!(windows) { + "C:\\repo-extra" + } else { + "/repo-extra" + }) + .unwrap(); + let hidden_root = AbsolutePathBuf::try_from(if cfg!(windows) { + "C:\\Users\\test\\.codex\\memories" + } else { + "/Users/test/.codex/memories" + }) + .unwrap(); + let profile = PermissionProfile::workspace_write_with( + std::slice::from_ref(&hidden_root), + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + + let summary = + summarize_permission_profile(&profile, &cwd, &[cwd.clone(), extra_root.clone()]); + + assert_eq!( + summary, + format!( + "workspace-write [workdir, /tmp, $TMPDIR, {}]", + extra_root.display() + ) + ); + } } From 10cf1f79dd4713abb4ef1dd8cef3401d22242d9d Mon Sep 17 00:00:00 2001 From: mchen-oai Date: Thu, 14 May 2026 18:26:50 -0700 Subject: [PATCH 14/51] Add `user_input_requested_during_turn` to MCP turn metadata (#22237) ## Why - Similar change as https://github.com/openai/codex/pull/21219 - Without change: MCP tool calls receive `_meta["x-codex-turn-metadata"]` with various key values. - Issue: MCP servers currently do not know if user input was requested during the turn (Ex: Model decides to prompt the user for approval mid-turn before making a possibly risky tool call). MCP servers may want to know this when tracking latency metrics because these instances are inflated. ## What Changed - With change: MCP turn metadata now includes `user_input_requested_during_turn` when a model-visible `request_user_input` call happened earlier in the turn, propagated in `_meta["x-codex-turn-metadata"]`. - `mark_turn_user_input_requested()` is called when user input is requested through either MCP elicitation (`mcp.rs`) or the `request_user_input` tool (`mod.rs`). - MCP tool call `_meta` is now built immediately before execution (`mcp_tool_call.rs`) so user input requested earlier in the same turn, including within the same tool call via elicitation, is reflected in the metadata. - Normal `/responses` turn metadata headers are unchanged. ## Verification - `codex-rs/core/src/session/mcp_tests.rs` - `codex-rs/core/src/tools/handlers/request_user_input_tests.rs` - `codex-rs/core/src/turn_metadata_tests.rs` - `codex-rs/core/tests/suite/search_tool.rs` --- codex-rs/core/src/mcp_tool_call.rs | 11 +- codex-rs/core/src/session/mcp.rs | 3 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/turn_metadata.rs | 21 ++ codex-rs/core/src/turn_metadata_tests.rs | 50 +++ .../core/tests/common/apps_test_server.rs | 120 ++++++- .../core/tests/suite/mcp_turn_metadata.rs | 312 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/openai_file_mcp.rs | 41 +-- codex-rs/core/tests/suite/search_tool.rs | 65 +--- 10 files changed, 531 insertions(+), 96 deletions(-) create mode 100644 codex-rs/core/tests/suite/mcp_turn_metadata.rs diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index fc8ce4d8ca..8f6e3afaf5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -192,12 +192,6 @@ pub(crate) async fn handle_mcp_tool_call( .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())), }; } - let request_meta = build_mcp_tool_call_request_meta( - turn_context.as_ref(), - &server, - &call_id, - metadata.as_ref(), - ); let connector_id = metadata .as_ref() .and_then(|metadata| metadata.connector_id.clone()); @@ -235,7 +229,6 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, metadata.as_ref(), - request_meta, mcp_app_resource_uri, ) .await; @@ -303,7 +296,6 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, metadata.as_ref(), - request_meta, mcp_app_resource_uri, ) .await @@ -320,7 +312,6 @@ async fn handle_approved_mcp_tool_call( call_id: &str, invocation: McpInvocation, metadata: Option<&McpToolApprovalMetadata>, - request_meta: Option, mcp_app_resource_uri: Option, ) -> HandledMcpToolCall { let server = invocation.server.clone(); @@ -353,6 +344,8 @@ async fn handle_approved_mcp_tool_call( }; let result = async { let rewritten_arguments = rewrite?; + let request_meta = + build_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata); let result = execute_mcp_tool_call( sess, turn_context, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index fcaaa17c57..9f27751f7e 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -154,6 +154,9 @@ impl Session { id, request, }); + turn_context + .turn_metadata_state + .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; rx_response.await.ok() } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5db9474721..eb909b2204 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2289,6 +2289,9 @@ impl Session { turn_id: turn_context.sub_id.clone(), questions: args.questions, }); + turn_context + .turn_metadata_state + .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; rx_response.await.ok() } diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 02760582f2..1b2c6b4b12 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use codex_utils_string::to_ascii_json_string; use serde::Serialize; @@ -23,6 +25,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; +const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; pub(crate) struct McpTurnMetadataContext<'a> { pub(crate) model: &'a str, @@ -186,6 +189,7 @@ pub(crate) struct TurnMetadataState { enriched_header: Arc>>, turn_started_at_unix_ms: Arc>>, responsesapi_client_metadata: Arc>>>, + user_input_requested_during_turn: Arc, enrichment_task: Arc>>>, } @@ -231,6 +235,7 @@ impl TurnMetadataState { enriched_header: Arc::new(RwLock::new(None)), turn_started_at_unix_ms: Arc::new(RwLock::new(None)), responsesapi_client_metadata: Arc::new(RwLock::new(None)), + user_input_requested_during_turn: Arc::new(AtomicBool::new(false)), enrichment_task: Arc::new(Mutex::new(None)), } } @@ -285,9 +290,25 @@ impl TurnMetadataState { metadata.remove(REASONING_EFFORT_KEY); } } + if self + .user_input_requested_during_turn + .load(Ordering::Relaxed) + { + metadata.insert( + USER_INPUT_REQUESTED_DURING_TURN_KEY.to_string(), + Value::Bool(true), + ); + } else { + metadata.remove(USER_INPUT_REQUESTED_DURING_TURN_KEY); + } Some(Value::Object(metadata)) } + pub(crate) fn mark_user_input_requested_during_turn(&self) { + self.user_input_requested_during_turn + .store(true, Ordering::Relaxed); + } + pub(crate) fn set_responsesapi_client_metadata( &self, responsesapi_client_metadata: HashMap, diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 2a38447f86..a9c7a7b87d 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -213,6 +213,56 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( ); } +#[test] +fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_request_meta() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*thread_source*/ None, + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!( + header_json + .get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .is_none() + ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert!(meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY).is_none()); + + state.mark_user_input_requested_during_turn(); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!( + header_json + .get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .is_none() + ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!( + meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .and_then(Value::as_bool), + Some(true) + ); +} + #[test] fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 757fc146f2..702f96ef2d 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -1,4 +1,10 @@ +use crate::test_codex::TestCodexBuilder; +use crate::test_codex::test_codex; use anyhow::Result; +use codex_core::config::Config; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_models_manager::bundled_models_response; use serde_json::Value; use serde_json::json; use wiremock::Mock; @@ -15,10 +21,21 @@ const CONNECTOR_NAME: &str = "Calendar"; const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; +const CODEX_APPS_META_KEY: &str = "_codex_apps"; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; const SEARCHABLE_TOOL_COUNT: usize = 100; +const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event"; +pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text"; +const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events"; +pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar_create_event"; +pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar_list_events"; +pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar_extract_text"; +pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; +pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text"; +pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event"; pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str = @@ -71,6 +88,103 @@ impl AppsTestServer { } } +pub fn configure_search_capable_model(config: &mut Config) { + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + config.model = Some("gpt-5.4".to_string()); + model.supports_search_tool = true; + config.model_catalog = Some(model_catalog); +} + +fn configure_apps(config: &mut Config, apps_base_url: &str) { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_base_url.to_string(); +} + +pub fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) { + configure_apps(config, apps_base_url); + configure_search_capable_model(config); +} + +pub fn apps_enabled_builder(apps_base_url: impl Into) -> TestCodexBuilder { + let apps_base_url = apps_base_url.into(); + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| configure_apps(config, apps_base_url.as_str())) +} + +pub fn search_capable_apps_builder(apps_base_url: impl Into) -> TestCodexBuilder { + let apps_base_url = apps_base_url.into(); + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| configure_search_capable_apps(config, apps_base_url.as_str())) +} + +fn apps_tool_call_id(body: &Value) -> Option<&str> { + body.get("params")? + .get("_meta")? + .get(CODEX_APPS_META_KEY)? + .get("call_id")? + .as_str() +} + +async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { + server + .received_requests() + .await + .expect("mock server should capture requests") + .into_iter() + .filter_map(|request| { + let body: Value = serde_json::from_slice(&request.body).ok()?; + (request.url.path() == "/api/codex/apps" + && body.get("method").and_then(Value::as_str) == Some("tools/call")) + .then_some(body) + }) + .collect() +} + +pub async fn recorded_apps_tool_call_by_call_id(server: &MockServer, call_id: &str) -> Value { + let matches = recorded_apps_tool_calls(server) + .await + .into_iter() + .filter(|body| apps_tool_call_id(body) == Some(call_id)) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "expected exactly one apps tools/call request for call_id {call_id}" + ); + matches + .into_iter() + .next() + .expect("matching apps tools/call request should be recorded") +} + +pub async fn recorded_apps_tool_call_by_name(server: &MockServer, tool_name: &str) -> Value { + let matches = recorded_apps_tool_calls(server) + .await + .into_iter() + .filter(|body| body.pointer("/params/name").and_then(Value::as_str) == Some(tool_name)) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "expected exactly one apps tools/call request for tool {tool_name}" + ); + matches + .into_iter() + .next() + .expect("matching apps tools/call request should be recorded") +} + async fn mount_oauth_metadata(server: &MockServer) { Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server/mcp")) @@ -187,7 +301,7 @@ impl Respond for CodexAppsJsonRpcResponder { "result": { "tools": [ { - "name": "calendar_create_event", + "name": CALENDAR_CREATE_EVENT_TOOL_NAME, "description": "Create a calendar event.", "annotations": { "readOnlyHint": false, @@ -217,7 +331,7 @@ impl Respond for CodexAppsJsonRpcResponder { } }, { - "name": "calendar_list_events", + "name": CALENDAR_LIST_EVENTS_TOOL_NAME, "description": "List calendar events.", "annotations": { "readOnlyHint": true @@ -242,7 +356,7 @@ impl Respond for CodexAppsJsonRpcResponder { } }, { - "name": "calendar_extract_text", + "name": CALENDAR_EXTRACT_TEXT_TOOL_NAME, "description": "Extract text from an uploaded document.", "annotations": { "readOnlyHint": false diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs new file mode 100644 index 0000000000..17fe33dc8b --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -0,0 +1,312 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_config::types::AppToolApproval; +use codex_core::config::Config; +use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashMap; + +fn set_calendar_approval_mode(config: &mut Config, approval_mode: AppToolApproval) { + let approval_mode = match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }; + let user_config_path = config.codex_home.join("config.toml").abs(); + let user_config = toml::from_str(&format!( + r#" +[apps.calendar] +default_tools_approval_mode = "{approval_mode}" +"# + )) + .expect("apps config should parse"); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&user_config_path, user_config); +} + +async fn submit_user_turn( + test: &TestCodex, + text: &str, + approval_policy: AskForApproval, + collaboration_mode: Option, +) -> Result<()> { + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + approvals_reviewer: None, + sandbox_policy, + permission_profile, + model: test.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode, + personality: None, + }) + .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "calendar-call-approval"; + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::ToolCallMcpElicitation) + .expect("test config should allow feature update"); + set_calendar_approval_mode(config, AppToolApproval::Prompt); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Use [$calendar](app://calendar) to create a calendar event.", + AskForApproval::OnRequest, + /*collaboration_mode*/ None, + ) + .await?; + + let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.call_id, call_id); + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ElicitationRequest(_)) + }) + .await + else { + unreachable!("event guard guarantees ElicitationRequest"); + }; + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(mock.requests().len(), 2); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, call_id).await; + + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/user_input_requested_during_turn"), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_call_metadata_records_prior_request_user_input_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let request_user_input_call_id = "user-input-call"; + let calendar_call_id = "calendar-call-after-user-input"; + let request_user_input_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + request_user_input_call_id, + "request_user_input", + &request_user_input_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call_with_namespace( + calendar_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + set_calendar_approval_mode(config, AppToolApproval::Approve); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Ask for confirmation, then create a calendar event.", + AskForApproval::Never, + Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ) + .await?; + + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, request_user_input_call_id); + + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "confirm_path".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + + let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.call_id, calendar_call_id); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(mock.requests().len(), 3); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, calendar_call_id).await; + + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/user_input_requested_during_turn"), + Some(&json!(true)) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2b5caf5a52..0d89516d3d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -57,6 +57,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_turn_metadata; mod model_overrides; mod model_switching; mod model_visible_layout; diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index 0f0dcf46f1..a25cc36a86 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -5,13 +5,16 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; -use codex_core::config::Config; -use codex_features::Feature; -use codex_login::CodexAuth; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::CALENDAR_EXTRACT_TEXT_TOOL_NAME; +use core_test_support::apps_test_server::DIRECT_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_HOOK_MATCHER; use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI; +use core_test_support::apps_test_server::SEARCH_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE as DOCUMENT_EXTRACT_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_name; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -20,7 +23,6 @@ use core_test_support::responses::ev_response_created; 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 pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -31,17 +33,6 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const DOCUMENT_EXTRACT_NAMESPACE: &str = "mcp__codex_apps__calendar"; -const DOCUMENT_EXTRACT_TOOL: &str = "_extract_text"; -const DOCUMENT_EXTRACT_HOOK_MATCHER: &str = "mcp__codex_apps__calendar_extract_text"; - -fn configure_apps(config: &mut Config, chatgpt_base_url: &str) { - if let Err(err) = config.features.enable(Feature::Apps) { - panic!("test config should allow feature update: {err}"); - } - config.chatgpt_base_url = chatgpt_base_url.to_string(); -} - fn write_post_tool_use_hook(home: &Path) -> Result<()> { let script_path = home.join("post_tool_use_hook.py"); let log_path = home.join("post_tool_use_hook_log.jsonl"); @@ -154,15 +145,13 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await; - let mut builder = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) .with_pre_build_hook(move |home| { if let Err(error) = write_post_tool_use_hook(home) { panic!("failed to write apps file post tool use hook fixture: {error}"); } }) .with_config(move |config| { - configure_apps(config, apps_server.chatgpt_base_url.as_str()); trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -192,20 +181,8 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res })) ); - let apps_tool_call = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find_map(|request| { - let body: Value = serde_json::from_slice(&request.body).ok()?; - (request.url.path() == "/api/codex/apps" - && body.get("method").and_then(Value::as_str) == Some("tools/call") - && body.pointer("/params/name").and_then(Value::as_str) - == Some("calendar_extract_text")) - .then_some(body) - }) - .expect("apps calendar_extract_text tools/call request should be recorded"); + let apps_tool_call = + recorded_apps_tool_call_by_name(&server, CALENDAR_EXTRACT_TEXT_TOOL_NAME).await; assert_eq!( apps_tool_call.pointer("/params/arguments/file"), diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index bb4ca84b29..69dc74dce3 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -7,7 +7,6 @@ use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; -use codex_models_manager::bundled_models_response; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -21,6 +20,15 @@ use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; +use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::configure_search_capable_apps; +use core_test_support::apps_test_server::configure_search_capable_model; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -34,7 +42,6 @@ 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::stdio_server_bin; -use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -48,11 +55,6 @@ const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 2] = [ "- Calendar: Plan events and manage your calendar.", ]; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; -const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; -const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; -const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; -const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; -const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -111,28 +113,6 @@ fn tool_search_output_has_namespace_child( namespace_child_tool(&output, namespace, tool_name).is_some() } -fn configure_search_capable_model(config: &mut Config) { - let mut model_catalog = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = model_catalog - .models - .iter_mut() - .find(|model| model.slug == "gpt-5.4") - .expect("gpt-5.4 exists in bundled models.json"); - config.model = Some("gpt-5.4".to_string()); - model.supports_search_tool = true; - config.model_catalog = Some(model_catalog); -} - -fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config.chatgpt_base_url = apps_base_url.to_string(); - configure_search_capable_model(config); -} - fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) { configure_search_capable_apps(config, apps_base_url); config @@ -141,16 +121,6 @@ fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) .expect("test config should allow feature update"); } -fn configure_apps(config: &mut Config, apps_base_url: &str) { - configure_search_capable_apps(config, apps_base_url); -} - -fn configured_builder(apps_base_url: String) -> TestCodexBuilder { - test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| configure_apps(config, apps_base_url.as_str())) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); @@ -321,7 +291,9 @@ async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(move |config| configure_apps(config, apps_server.chatgpt_base_url.as_str())); + .with_config(move |config| { + configure_search_capable_apps(config, apps_server.chatgpt_base_url.as_str()) + }); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -585,18 +557,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - assert_eq!(requests.len(), 3); let first_request_body = requests[0].body_json(); - let apps_tool_call = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find_map(|request| { - let body: Value = serde_json::from_slice(&request.body).ok()?; - (request.url.path() == "/api/codex/apps" - && body.get("method").and_then(Value::as_str) == Some("tools/call")) - .then_some(body) - }) - .expect("apps tools/call request should be recorded"); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, "calendar-call-1").await; assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), From db51df0f44ab150f1e50b0205e4bd64c8638a3d1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 18:36:20 -0700 Subject: [PATCH 15/51] ci: support signed macOS release promotion (#22737) ## Why `rust-release.yml` can create unsigned macOS artifacts for external signing, but there was no signed resume path after those artifacts returned from a secure enclave. Release operators need a way to reuse the first run artifacts, ingest signed macOS binaries and DMGs, and continue the normal signed release path without rebuilding every platform or treating handoff assets as final release assets. ## How this is meant to be used First, start the release as an unsigned macOS build against the release tag: ```shell gh workflow run rust-release.yml \ --repo openai/codex \ --ref rust-vX.Y.Z \ -f release_mode=build_unsigned ``` That run builds the normal Linux/Windows artifacts and publishes unsigned macOS handoff artifacts. The unsigned macOS binaries are then copied to the secure enclave, signed and notarized there, packaged as a signed handoff archive, and uploaded back to the GitHub Release for the same tag. The signed handoff asset should contain either target directories such as `aarch64-apple-darwin/` and `x86_64-apple-darwin/`, or artifact directories such as `aarch64-apple-darwin-app-server/`. The promote workflow accepts either layout. The directories should contain the signed binaries and, for primary macOS bundles, the signed and stapled DMGs. For example, after signing, upload the handoff asset to the release: ```shell gh release upload rust-vX.Y.Z \ signed-macos-rust-vX.Y.Z.tar.zst \ --repo openai/codex \ --clobber ``` Then start the promotion run. `unsigned_run_id` is the workflow run id from the first `build_unsigned` run, and `signed_macos_asset` is the exact Release asset name uploaded by the secure enclave: ```shell gh workflow run rust-release.yml \ --repo openai/codex \ --ref rust-vX.Y.Z \ -f release_mode=promote_signed \ -f unsigned_run_id=1234567890 \ -f signed_macos_asset=signed-macos-rust-vX.Y.Z.tar.zst \ -f signed_macos_sha256= ``` The `signed_macos_sha256` input is optional, but when provided the promotion run verifies the handoff archive before unpacking it. The promotion run also validates that `unsigned_run_id` points to a successful manual `rust-release` run for the same tag and commit before importing artifacts. ## What Changed - Add explicit manual `release_mode` values for `build_unsigned` and `promote_signed` while keeping `sign_macos` as a deprecated compatibility input. - Add promote inputs for `unsigned_run_id`, `signed_macos_asset`, and optional `signed_macos_sha256`. - Add a `stage-signed-macos` job that downloads the signed handoff asset from the GitHub Release, verifies signed binaries and stapled DMGs, repacks normal macOS release artifacts, and builds macOS Python runtime wheels. - Teach the release job to download Part 1 artifacts from the unsigned run, discard unsigned macOS staging artifacts, re-upload promoted Linux and Windows artifacts for npm staging, and then run the signed release tail. - Validate that `unsigned_run_id` points to a successful manual `rust-release` run for the same tag and commit before importing artifacts. - Limit unsigned macOS artifact upload to the unsigned build path so normal signed releases do not publish unsigned handoff binaries. - Clean up unsigned and signed handoff release assets after successful promotion. ## Verification - Parsed `.github/workflows/rust-release.yml` with Ruby YAML loading. No developers.openai.com documentation update is needed. --- .github/workflows/rust-release.yml | 469 ++++++++++++++++++++++++++++- 1 file changed, 459 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ca082812c6..2dc92cf684 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,6 +4,13 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` +# +# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, +# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff +# archive as a GitHub Release asset, then manually dispatch +# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. +# The signed handoff archive should contain target or artifact directories such +# as `aarch64-apple-darwin/` with signed binaries and signed DMGs. name: rust-release on: @@ -12,11 +19,31 @@ on: - "rust-v*.*.*" workflow_dispatch: inputs: + release_mode: + description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + required: false + type: choice + default: build_unsigned + options: + - build_unsigned + - promote_signed sign_macos: - description: "Sign and notarize macOS release artifacts." + description: "Deprecated compatibility input; use release_mode instead." required: false type: boolean - default: true + default: false + unsigned_run_id: + description: "For promote_signed: workflow run id from the build_unsigned run." + required: false + type: string + signed_macos_asset: + description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." + required: false + type: string + signed_macos_sha256: + description: "For promote_signed: optional SHA-256 of signed_macos_asset." + required: false + type: string concurrency: group: ${{ github.workflow }} @@ -33,14 +60,57 @@ jobs: - name: Validate tag matches Cargo.toml version shell: bash env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then - echo "❌ Manual rust-release runs must set sign_macos=false" - exit 1 + case "${RELEASE_MODE}" in + signed) + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" + exit 1 + fi + ;; + build_unsigned) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=build_unsigned is only valid for manual runs" + exit 1 + fi + ;; + promote_signed) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=promote_signed is only valid for manual runs" + exit 1 + fi + if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then + echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" + exit 1 + fi + if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then + echo "❌ release_mode=promote_signed requires signed_macos_asset" + exit 1 + fi + if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then + echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" + exit 1 + fi + if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then + echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" + exit 1 + fi + ;; + *) + echo "❌ Unknown release_mode '${RELEASE_MODE}'" + exit 1 + ;; + esac + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then + echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." fi # 1. Must be a tag and match the regex @@ -62,6 +132,7 @@ jobs: echo "::endgroup::" build: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -78,7 +149,7 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} strategy: fail-fast: false @@ -310,7 +381,7 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash run: | @@ -335,7 +406,7 @@ jobs: zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Upload unsigned macOS artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -553,7 +624,230 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* + stage-signed-macos: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} + needs: tag-check + name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download signed macOS handoff + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} + run: | + set -euo pipefail + + download_dir="${RUNNER_TEMP}/signed-macos-download" + handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" + rm -rf "$download_dir" "$handoff_dir" + mkdir -p "$download_dir" "$handoff_dir" + + gh release download "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$SIGNED_MACOS_ASSET" \ + --dir "$download_dir" + + asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" + if [[ "$asset_count" != "1" ]]; then + echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" + find "$download_dir" -maxdepth 1 -type f -print + exit 1 + fi + + asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" + if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then + expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" + actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" + echo "expected: ${expected_sha}" + echo "actual: ${actual_sha}" + exit 1 + fi + fi + + asset_name="$(basename "$asset_path")" + case "$asset_name" in + *.tar.zst) + zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - + ;; + *.tar.gz|*.tgz) + tar -C "$handoff_dir" -xzf "$asset_path" + ;; + *.zip) + ditto -x -k "$asset_path" "$handoff_dir" + ;; + *) + echo "Unsupported signed macOS handoff archive format: ${asset_name}" + exit 1 + ;; + esac + + echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + + - name: Stage signed macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + artifact_name="${{ matrix.artifact_name }}" + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + fi + if [[ ! -d "$source_dir" ]]; then + echo "Signed macOS handoff is missing ${artifact_name}/" + echo "Expected either:" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + exit 1 + fi + + dest="dist/${target}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + source_path="${source_dir}/${binary}" + if [[ ! -f "$source_path" ]]; then + source_path="${source_dir}/${binary}-${target}" + fi + if [[ ! -f "$source_path" ]]; then + echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" + exit 1 + fi + + release_path="${dest}/${binary}-${target}" + ditto "$source_path" "$release_path" + chmod 0755 "$release_path" + codesign --verify --strict --verbose=2 "$release_path" + done + + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + dmg_name="codex-${target}.dmg" + dmg_source="${source_dir}/${dmg_name}" + if [[ ! -f "$dmg_source" ]]; then + echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" + exit 1 + fi + + codesign --verify --strict --verbose=2 "$dmg_source" + xcrun stapler validate "$dmg_source" + cp "$dmg_source" "$dest/$dmg_name" + fi + + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + python3 \ + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + + - name: Compress artifacts + shell: bash + run: | + set -euo pipefail + + dest="dist/${{ matrix.target }}" + for f in "$dest"/*; do + base="$(basename "$f")" + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + continue + fi + + tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" + zstd -T0 -19 --rm "$dest/$base" + done + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }} + path: | + codex-rs/dist/${{ matrix.target }}/* + build-windows: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: @@ -561,6 +855,7 @@ jobs: secrets: inherit argument-comment-lint-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -568,23 +863,53 @@ jobs: publish: true zsh-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml release: needs: + - tag-check - build + - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets + if: >- + ${{ + always() && + needs.tag-check.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + inputs.release_mode == 'promote_signed' && + needs.stage-signed-macos.result == 'success' && + needs.build.result == 'skipped' && + needs.build-windows.result == 'skipped' && + needs.argument-comment-lint-release-assets.result == 'skipped' && + needs.zsh-release-assets.result == 'skipped' + ) || + ( + (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && + needs.build.result == 'success' && + needs.stage-signed-macos.result == 'skipped' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' + ) + ) + }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} @@ -602,6 +927,7 @@ jobs: - name: Define release mode id: release_mode run: | + echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - name: Generate release notes from tag commit message @@ -628,6 +954,100 @@ jobs: with: path: dist + - name: Validate unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --json conclusion,event,headBranch,headSha,status,workflowName,url \ + --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" + IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" + expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + + if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$event" != "workflow_dispatch" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_sha" != "$expected_head_sha" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" + echo "Run URL: ${run_url}" + exit 1 + fi + + - name: Download artifacts from unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh run download "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist + + - name: Remove unsigned macOS staging artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + run: | + set -euo pipefail + find dist -mindepth 1 -maxdepth 1 -type d \ + -name '*-apple-darwin*-unsigned' \ + -exec rm -rf {} + + + - name: Re-upload promoted Linux x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-unknown-linux-musl + path: dist/x86_64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Linux arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-unknown-linux-musl + path: dist/aarch64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Windows x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-pc-windows-msvc + path: dist/x86_64-pc-windows-msvc/* + if-no-files-found: error + + - name: Re-upload promoted Windows arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-pc-windows-msvc + path: dist/aarch64-pc-windows-msvc/* + if-no-files-found: error + - name: List run: ls -R dist/ @@ -742,8 +1162,10 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | + workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ --release-version "$RELEASE_VERSION" \ + --workflow-url "$workflow_url" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk @@ -761,11 +1183,38 @@ jobs: tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** + overwrite_files: true make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} + - name: Clean up signed promotion handoff assets + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ + --jq '.[] | [.id, .name] | @tsv' | + while IFS=$'\t' read -r asset_id asset_name; do + if [[ -z "$asset_id" || -z "$asset_name" ]]; then + continue + fi + + delete_asset=false + if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then + delete_asset=true + fi + + if [[ "$delete_asset" == "true" ]]; then + echo "Deleting release asset ${asset_name}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + fi + done + - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: From 06bb508547d391142ab96a1b3ce6874d7e511ca1 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Thu, 14 May 2026 18:43:18 -0700 Subject: [PATCH 16/51] Stabilize compact rollback follow-up test (#22303) ## Summary - add the missing response.created event to the mocked empty follow-up response in the compact rollback test - keep the fix scoped to the flaky mocked stream shape, without increasing timeouts ## Recent flakes on main - `snapshot_rollback_followup_turn_trims_context_updates` failed in `rust-ci-full` on `main` in the Ubuntu remote test job on 2026-05-14: https://github.com/openai/codex/actions/runs/25891434395/job/76095284830 - The same `compact_resume_fork` suite also failed recently on `main` with `snapshot_rollback_past_compaction_replays_append_only_history`, which has the same mocked Responses stream shape sensitivity this PR is tightening: https://github.com/openai/codex/actions/runs/25892437363/job/76098329098 ## Verification - env -u CODEX_SANDBOX_NETWORK_DISABLED cargo test -p codex-core --test all snapshot_rollback_followup_turn_trims_context_updates -- --nocapture - repeated the same focused test 3 consecutive times locally - UV_CACHE_DIR=/private/tmp/uv-cache-codex-fmt just fmt --- codex-rs/core/tests/suite/compact_resume_fork.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 2788c8ef21..430f23a867 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -30,6 +30,7 @@ use core_test_support::responses::ResponseMock; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -535,7 +536,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { ev_assistant_message("m2", "turn 2 assistant"), ev_completed("r2"), ]), - sse(vec![ev_completed("r3")]), + sse(vec![ev_response_created("r3"), ev_completed("r3")]), ], ) .await; From 3c6d727810a2783fd03c5f0ab86a195bfaa7a752 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 18:47:44 -0700 Subject: [PATCH 17/51] permissions: resolve profile identity with constraints (#22683) ## Why This PR is the invariant-cleanup layer that follows the workspace-roots base merged in [#22610](https://github.com/openai/codex/pull/22610). #22610 adds `[permissions..workspace_roots]` and keeps runtime workspace roots separate from the raw permission profile, but its in-memory representation is intentionally transitional: `Permissions` still carries the selected profile identity next to a constrained `PermissionProfile`. That makes APIs such as `set_constrained_permission_profile_with_active_profile()` fragile because the id and value only mean the right thing when every caller keeps them in sync. This PR introduces a single resolved profile state so profile identity, `extends`, the profile value, and profile-declared workspace roots travel together. The next PR, [#22611](https://github.com/openai/codex/pull/22611), builds on this by changing the app-server turn API to select permission profiles by id plus runtime workspace roots. ## Stack Context - #22610, now merged: adds profile-declared `workspace_roots`, runtime workspace roots, and `:workspace_roots` materialization. - This PR: replaces the parallel active-profile/profile-value fields with `PermissionProfileState`. - #22611: switches app-server turn updates toward profile ids plus runtime workspace roots. - #22612: updates TUI/exec summaries to show the effective workspace roots. Keeping this separate from #22611 is deliberate: reviewers can validate the internal state invariant before reviewing the app-server protocol migration. ## What Changed - Added `ResolvedPermissionProfile::{Legacy, BuiltIn, Named}` and `PermissionProfileState`. - Typed built-in profile ids with `BuiltInPermissionProfileId`. - Moved selected profile identity and profile-declared workspace roots into the resolved state. - Replaced `Permissions` parallel profile fields with one `permission_profile_state`. - Removed `set_constrained_permission_profile_with_active_profile()` from session sync paths. - Kept trusted session replay/`SessionConfigured` compatibility through explicit session snapshot helpers. - Updated session configuration, MCP initialization, app-server, exec, TUI, and guardian call sites to consume `&PermissionProfile` directly. ## Review Guide Start with `codex-rs/core/src/config/resolved_permission_profile.rs`; it is the new invariant boundary. Then review `codex-rs/core/src/config/mod.rs` to see how config loading records active profile identity and profile workspace roots. The remaining call-site changes are mostly mechanical fallout from `Permissions::permission_profile()` returning `&PermissionProfile` instead of `&Constrained`. ## Verification The existing config/session coverage now constructs and asserts through `PermissionProfileState`. The workspace-root config test also asserts that profile-declared roots are preserved in the resolved state, which is the behavior #22611 relies on when runtime roots become mutable through the app-server API. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22683). * #22612 * #22611 * __->__ #22683 --- codex-rs/app-server/src/lib.rs | 2 +- .../command_exec_processor.rs | 8 +- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/codex-mcp/src/connection_manager.rs | 9 +- codex-rs/core/src/config/config_tests.rs | 45 ++-- codex-rs/core/src/config/mod.rs | 169 ++++++++---- .../src/config/resolved_permission_profile.rs | 246 ++++++++++++++++++ codex-rs/core/src/guardian/review_session.rs | 2 +- codex-rs/core/src/guardian/tests.rs | 8 +- codex-rs/core/src/session/mod.rs | 18 +- codex-rs/core/src/session/session.rs | 102 +++++--- codex-rs/core/src/session/tests.rs | 117 +++++---- codex-rs/core/src/session/turn_context.rs | 8 +- .../src/tools/handlers/multi_agents_tests.rs | 17 +- codex-rs/exec/src/lib.rs | 2 +- codex-rs/thread-manager-sample/src/main.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 2 +- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/startup_prompts.rs | 2 +- codex-rs/tui/src/app/tests.rs | 9 +- codex-rs/tui/src/app/thread_session_state.rs | 5 +- .../tui/src/chatwidget/permission_popups.rs | 2 +- codex-rs/tui/src/chatwidget/session_flow.rs | 13 +- .../src/chatwidget/tests/history_replay.rs | 2 +- codex-rs/tui/src/status/tests.rs | 20 +- 25 files changed, 582 insertions(+), 234 deletions(-) create mode 100644 codex-rs/core/src/config/resolved_permission_profile.rs diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f2ac6ca00a..673dec6341 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -589,7 +589,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile()) { config_warnings.push(ConfigWarningNotification { summary: warning, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index d1781db5ff..2ae11363a5 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -164,7 +164,7 @@ impl CommandExecRequestProcessor { let started_network_proxy = match self.config.permissions.network.as_ref() { Some(spec) => match spec .start_proxy( - self.config.permissions.permission_profile().get(), + self.config.permissions.permission_profile(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -243,8 +243,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile() - .can_set(&effective_permission_profile) + .can_set_permission_profile(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { @@ -264,8 +263,7 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile() - .can_set(&permission_profile) + .can_set_permission_profile(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; permission_profile } else { diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index bdcf0a191f..bc9d6172f2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -230,7 +230,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile().get(), + config.permissions.permission_profile(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -965,7 +965,6 @@ mod tests { let actual = config .permissions .permission_profile() - .get() .file_system_sandbox_policy(); let expected = codex_protocol::models::PermissionProfile::workspace_write() .file_system_sandbox_policy(); @@ -1008,7 +1007,6 @@ mod tests { let actual = config .permissions .permission_profile() - .get() .file_system_sandbox_policy(); let expected = codex_protocol::models::PermissionProfile::workspace_write() .file_system_sandbox_policy(); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c5593a5598..e8dc36b9b2 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -80,6 +80,13 @@ impl McpConnectionManager { pub fn new_uninitialized( approval_policy: &Constrained, permission_profile: &Constrained, + ) -> Self { + Self::new_uninitialized_with_permission_profile(approval_policy, permission_profile.get()) + } + + pub fn new_uninitialized_with_permission_profile( + approval_policy: &Constrained, + permission_profile: &PermissionProfile, ) -> Self { Self { clients: HashMap::new(), @@ -87,7 +94,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), - permission_profile.get().clone(), + permission_profile.clone(), /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2e077bae35..7700ea230e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -105,6 +105,18 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; +fn active_permission_profile_state( + permission_profile: PermissionProfile, + profile_id: impl Into, +) -> PermissionProfileState { + PermissionProfileState::from_constrained_active_profile( + Constrained::allow_any(permission_profile), + Some(ActivePermissionProfile::new(profile_id)), + Vec::new(), + ) + .expect("active permission profile state should be valid") +} + fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1893,8 +1905,9 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( ) .await?; + let profile_root_abs = profile_root.abs(); let policy = config.permissions.file_system_sandbox_policy(); - for root in [cwd.abs(), runtime_root.abs(), profile_root.abs()] { + for root in [cwd.abs(), runtime_root.abs(), profile_root_abs.clone()] { assert!( policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), "expected workspace root to be writable, policy: {policy:?}" @@ -1908,6 +1921,10 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( "expected .codex carveout under {root:?}, policy: {policy:?}" ); } + assert_eq!( + config.permissions.profile_workspace_roots(), + std::slice::from_ref(&profile_root_abs) + ); assert_eq!( config.permissions.active_permission_profile(), Some(ActivePermissionProfile::new("dev")) @@ -7541,12 +7558,10 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - constrained_permissions_profile: Constrained::allow_any( - PermissionProfile::read_only() - ), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, @@ -7993,10 +8008,10 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, @@ -8157,10 +8172,10 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, @@ -8306,10 +8321,10 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - constrained_permissions_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7472e5c576..d9f744155d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -134,6 +134,7 @@ mod managed_features; mod network_proxy_spec; mod otel; mod permissions; +mod resolved_permission_profile; #[cfg(test)] mod schema; pub use codex_config::ConfigLoadOptions; @@ -148,6 +149,7 @@ pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub(crate) use permissions::resolve_permission_profile; +pub(crate) use resolved_permission_profile::PermissionProfileState; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; @@ -247,15 +249,11 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Canonical constrained permissions profile before runtime workspace-root - /// materialization has been applied. - constrained_permissions_profile: Constrained, - /// Named or implicit built-in profile selected by config, rather than an - /// ad-hoc override. - active_permission_profile: Option, + /// Constrained permission profile plus its selected profile identity, if + /// the profile came from a built-in or named config profile. + permission_profile_state: PermissionProfileState, /// Thread-scoped runtime workspace roots. Symbolic `:workspace_roots` - /// entries in `constrained_permissions_profile` are materialized against - /// these roots. + /// entries in the permission profile are materialized against these roots. workspace_roots: Vec, /// Effective network configuration applied to all spawned processes. pub network: Option, @@ -283,35 +281,103 @@ impl Permissions { pub fn from_approval_and_profile( approval_policy: Constrained, permission_profile: Constrained, - ) -> Self { - Self { + ) -> ConstraintResult { + Ok(Self { approval_policy, - constrained_permissions_profile: permission_profile, - active_permission_profile: None, + permission_profile_state: PermissionProfileState::from_constrained_legacy( + permission_profile, + )?, workspace_roots: Vec::new(), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - } + }) } - /// Borrow the constrained canonical profile. This preserves the raw - /// symbolic `:workspace_roots` form for session/thread state. - pub fn permission_profile(&self) -> &Constrained { - &self.constrained_permissions_profile + pub(crate) fn permission_profile_state(&self) -> &PermissionProfileState { + &self.permission_profile_state } - /// Set the full constrained profile value and preserve the active profile - /// sidecar when the caller has already validated both together. - pub fn set_constrained_permission_profile_with_active_profile( + pub(crate) fn set_permission_profile_state( + &mut self, + permission_profile_state: PermissionProfileState, + ) { + self.permission_profile_state = permission_profile_state; + } + + /// Apply a permission profile snapshot emitted by core session state. + /// + /// This is a trusted-state bridge for consumers of `SessionConfigured`. + /// Config loading and app-server selection should resolve named profiles + /// through config instead of constructing this pair directly. + pub fn set_permission_profile_from_session_snapshot( + &mut self, + permission_profile: PermissionProfile, + active_permission_profile: Option, + ) -> ConstraintResult<()> { + self.set_permission_profile_from_session_snapshot_with_profile_workspace_roots( + permission_profile, + active_permission_profile, + Vec::new(), + ) + } + + pub fn set_permission_profile_from_session_snapshot_with_profile_workspace_roots( + &mut self, + permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult<()> { + self.permission_profile_state.set_active_permission_profile( + permission_profile, + active_permission_profile, + profile_workspace_roots, + ) + } + + /// Replace the current permission constraints with a trusted session + /// snapshot. This is only for clients that must mirror core session state + /// after their local config constraints reject the snapshot. + pub fn replace_permission_profile_from_session_snapshot( &mut self, permission_profile: Constrained, active_permission_profile: Option, - ) { - self.constrained_permissions_profile = permission_profile; - self.active_permission_profile = active_permission_profile; + ) -> ConstraintResult<()> { + self.replace_permission_profile_from_session_snapshot_with_profile_workspace_roots( + permission_profile, + active_permission_profile, + Vec::new(), + ) + } + + pub fn replace_permission_profile_from_session_snapshot_with_profile_workspace_roots( + &mut self, + permission_profile: Constrained, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult<()> { + self.permission_profile_state = PermissionProfileState::from_constrained_active_profile( + permission_profile, + active_permission_profile, + profile_workspace_roots, + )?; + Ok(()) + } + + /// Borrow the canonical profile before runtime workspace-root + /// materialization has been applied. + pub fn permission_profile(&self) -> &PermissionProfile { + self.permission_profile_state.permission_profile() + } + + pub fn can_set_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile_state + .can_set_legacy_permission_profile(permission_profile) } pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { @@ -328,9 +394,12 @@ impl Permissions { &self.workspace_roots } + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + fn materialized_permission_profile(&self) -> PermissionProfile { - self.constrained_permissions_profile - .get() + self.permission_profile() .clone() .materialize_project_roots_with_workspace_roots(&self.workspace_roots) } @@ -343,7 +412,7 @@ impl Permissions { /// Named profile selected by config, if the current profile has one. pub fn active_permission_profile(&self) -> Option { - self.active_permission_profile.clone() + self.permission_profile_state.active_permission_profile() } /// Effective filesystem sandbox policy derived from the canonical profile. @@ -354,9 +423,7 @@ impl Permissions { /// Effective network sandbox policy derived from the canonical profile. pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.constrained_permissions_profile - .get() - .network_sandbox_policy() + self.permission_profile().network_sandbox_policy() } /// Legacy compatibility projection derived from the canonical profile. @@ -386,8 +453,8 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); - self.constrained_permissions_profile - .can_set(&permission_profile) + self.permission_profile_state + .can_set_legacy_permission_profile(&permission_profile) } /// Set permissions from a legacy sandbox policy and keep every permission @@ -427,9 +494,8 @@ impl Permissions { ], }; - self.constrained_permissions_profile - .set(permission_profile)?; - self.active_permission_profile = None; + self.permission_profile_state + .set_legacy_permission_profile(permission_profile)?; Ok(()) } @@ -438,23 +504,8 @@ impl Permissions { &mut self, permission_profile: PermissionProfile, ) -> ConstraintResult<()> { - self.set_permission_profile_with_active_profile( - permission_profile, - /*active_permission_profile*/ None, - ) - } - - /// Set permissions from the canonical profile and record the named source - /// profile, if one is known. - pub fn set_permission_profile_with_active_profile( - &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, - ) -> ConstraintResult<()> { - self.constrained_permissions_profile - .set(permission_profile)?; - self.active_permission_profile = active_permission_profile; - Ok(()) + self.permission_profile_state + .set_legacy_permission_profile(permission_profile) } } @@ -2532,6 +2583,7 @@ impl Config { permission_profile, file_system_sandbox_policy, mut active_permission_profile, + mut profile_workspace_roots, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -2584,6 +2636,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) } else if profiles_are_active { let default_permissions = default_permissions.unwrap_or_else(|| { @@ -2674,6 +2727,7 @@ impl Config { permission_profile, file_system_sandbox_policy, active_permission_profile, + configured_workspace_roots, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2732,6 +2786,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) }; if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { @@ -3155,6 +3210,7 @@ impl Config { // The selected profile no longer describes the effective // permissions after requirements forced a fallback. active_permission_profile = None; + profile_workspace_roots.clear(); } apply_requirement_constrained_value( "web_search_mode", @@ -3225,6 +3281,12 @@ impl Config { .value .set(effective_permission_profile) .map_err(std::io::Error::from)?; + let permission_profile_state = PermissionProfileState::from_constrained_active_profile( + constrained_permission_profile.value, + active_permission_profile, + profile_workspace_roots, + ) + .map_err(std::io::Error::from)?; let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); let config = Self { model, @@ -3240,8 +3302,7 @@ impl Config { startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - constrained_permissions_profile: constrained_permission_profile.value, - active_permission_profile, + permission_profile_state, workspace_roots, network, allow_login_shell, @@ -3526,7 +3587,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile().get(), + self.permissions.permission_profile(), PermissionProfile::Disabled ) && self .config_layer_stack diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs new file mode 100644 index 0000000000..c7cf264dae --- /dev/null +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -0,0 +1,246 @@ +use codex_config::Constrained; +use codex_config::ConstraintResult; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuiltInPermissionProfileId { + ReadOnly, + Workspace, + DangerFullAccess, +} + +impl BuiltInPermissionProfileId { + fn from_str(id: &str) -> Option { + match id { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY => Some(Self::ReadOnly), + BUILT_IN_PERMISSION_PROFILE_WORKSPACE => Some(Self::Workspace), + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS => Some(Self::DangerFullAccess), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + Self::Workspace => BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + Self::DangerFullAccess => BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResolvedPermissionProfile { + Legacy(LegacyPermissionProfile), + BuiltIn(BuiltInPermissionProfile), + Named(NamedPermissionProfile), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LegacyPermissionProfile { + permission_profile: PermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BuiltInPermissionProfile { + id: BuiltInPermissionProfileId, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NamedPermissionProfile { + id: String, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +impl ResolvedPermissionProfile { + pub(crate) fn from_active_profile( + permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> Self { + let Some(active_permission_profile) = active_permission_profile else { + return Self::legacy(permission_profile); + }; + + let ActivePermissionProfile { id, extends } = active_permission_profile; + if let Some(built_in_id) = BuiltInPermissionProfileId::from_str(&id) { + Self::BuiltIn(BuiltInPermissionProfile { + id: built_in_id, + extends, + permission_profile, + profile_workspace_roots, + }) + } else { + Self::Named(NamedPermissionProfile { + id, + extends, + permission_profile, + profile_workspace_roots, + }) + } + } + + pub(crate) fn legacy(permission_profile: PermissionProfile) -> Self { + Self::Legacy(LegacyPermissionProfile { permission_profile }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + match self { + Self::Legacy(profile) => &profile.permission_profile, + Self::BuiltIn(profile) => &profile.permission_profile, + Self::Named(profile) => &profile.permission_profile, + } + } + + fn with_permission_profile(&self, permission_profile: PermissionProfile) -> Self { + match self { + Self::Legacy(_) => Self::legacy(permission_profile), + Self::BuiltIn(profile) => Self::BuiltIn(BuiltInPermissionProfile { + id: profile.id, + extends: profile.extends.clone(), + permission_profile, + profile_workspace_roots: profile.profile_workspace_roots.clone(), + }), + Self::Named(profile) => Self::Named(NamedPermissionProfile { + id: profile.id.clone(), + extends: profile.extends.clone(), + permission_profile, + profile_workspace_roots: profile.profile_workspace_roots.clone(), + }), + } + } + + pub(crate) fn active_permission_profile(&self) -> Option { + match self { + Self::Legacy(_) => None, + Self::BuiltIn(profile) => Some(ActivePermissionProfile { + id: profile.id.as_str().to_string(), + extends: profile.extends.clone(), + }), + Self::Named(profile) => Some(ActivePermissionProfile { + id: profile.id.clone(), + extends: profile.extends.clone(), + }), + } + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + match self { + Self::Legacy(_) => &[], + Self::BuiltIn(profile) => &profile.profile_workspace_roots, + Self::Named(profile) => &profile.profile_workspace_roots, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PermissionProfileState { + resolved_permission_profile: Constrained, +} + +impl PermissionProfileState { + pub(crate) fn from_constrained_legacy( + constrained_permission_profile: Constrained, + ) -> ConstraintResult { + let resolved = + ResolvedPermissionProfile::legacy(constrained_permission_profile.get().clone()); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_active_profile( + constrained_permission_profile: Constrained, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult { + let resolved = ResolvedPermissionProfile::from_active_profile( + constrained_permission_profile.get().clone(), + active_permission_profile, + profile_workspace_roots, + ); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_resolved( + constrained_permission_profile: Constrained, + resolved_permission_profile: ResolvedPermissionProfile, + ) -> ConstraintResult { + let permission_profile_constraint = constrained_permission_profile; + let resolved_permission_profile = Constrained::new( + resolved_permission_profile, + move |candidate: &ResolvedPermissionProfile| { + permission_profile_constraint.can_set(candidate.permission_profile()) + }, + )?; + Ok(Self { + resolved_permission_profile, + }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.get().permission_profile() + } + + pub(crate) fn clone_with_permission_profile( + &self, + permission_profile: PermissionProfile, + ) -> ConstraintResult { + let candidate = self + .resolved_permission_profile + .get() + .with_permission_profile(permission_profile); + let mut state = self.clone(); + state.resolved_permission_profile.set(candidate)?; + Ok(state) + } + + pub(crate) fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile + .get() + .active_permission_profile() + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile + .get() + .profile_workspace_roots() + } + + pub(crate) fn can_set_legacy_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + let candidate = ResolvedPermissionProfile::legacy(permission_profile.clone()); + self.resolved_permission_profile.can_set(&candidate) + } + + pub(crate) fn set_legacy_permission_profile( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.resolved_permission_profile + .set(ResolvedPermissionProfile::legacy(permission_profile)) + } + + pub(crate) fn set_active_permission_profile( + &mut self, + permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult<()> { + let candidate = ResolvedPermissionProfile::from_active_profile( + permission_profile, + active_permission_profile, + profile_workspace_roots, + ); + self.resolved_permission_profile.set(candidate) + } +} diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 25b84420dc..2f15548919 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -918,7 +918,7 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.permissions.network = Some(NetworkProxySpec::from_config_and_constraints( live_network_config, network_constraints, - guardian_config.permissions.permission_profile().get(), + guardian_config.permissions.permission_profile(), )?); } for feature in [ diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 18400fa230..742b3c8ae4 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2163,7 +2163,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { }), ..Default::default() }), - parent_config.permissions.permission_profile().get(), + parent_config.permissions.permission_profile(), ) .expect("network proxy spec"); parent_config.permissions.network = Some(network.clone()); @@ -2191,9 +2191,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { ); assert_eq!( guardian_config.permissions.permission_profile(), - &Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - )) + &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()) ); } @@ -2230,7 +2228,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( parent_network, /*requirements*/ None, - parent_config.permissions.permission_profile().get(), + parent_config.permissions.permission_profile(), ) .expect("parent network proxy spec"), ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index eb909b2204..1938ae2a81 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -173,6 +173,7 @@ use crate::compact::collect_user_messages; use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; +use crate::config::PermissionProfileState; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; @@ -617,8 +618,7 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: session_permission_profile_from_config(&config)?, - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -818,18 +818,18 @@ fn get_service_tier( .then_some(ServiceTier::Fast.request_value().to_string()) } -fn session_permission_profile_from_config( +fn session_permission_profile_state_from_config( config: &Config, -) -> CodexResult> { - let mut session_permission_profile = config.permissions.permission_profile().clone(); - session_permission_profile - .set(config.permissions.effective_permission_profile()) +) -> CodexResult { + config + .permissions + .permission_profile_state() + .clone_with_permission_profile(config.permissions.effective_permission_profile()) .map_err(|err| { CodexErr::Fatal(format!( "failed to materialize workspace roots for session permissions: {err}" )) - })?; - Ok(session_permission_profile) + }) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index a15fbedc0d..5f140ee36d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -63,11 +63,10 @@ pub(crate) struct SessionConfiguration { /// When to escalate for approval for execution pub(super) approval_policy: Constrained, pub(super) approvals_reviewer: ApprovalsReviewer, - /// Canonical permission profile for the session. - pub(super) permission_profile: Constrained, - /// Named or implicit built-in permissions profile selected from config, if - /// any. - pub(super) active_permission_profile: Option, + /// Permission profile state for the session. Keep the constrained profile + /// and selected profile id in sync by using the methods below instead of + /// mutating the fields independently. + pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Absolute working directory that should be treated as the *root* of the @@ -103,12 +102,32 @@ impl SessionConfiguration { &self.codex_home } + pub(super) fn permission_profile_state(&self) -> &PermissionProfileState { + &self.permission_profile_state + } + pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + self.permission_profile_state.permission_profile().clone() } pub(super) fn active_permission_profile(&self) -> Option { - self.active_permission_profile.clone() + self.permission_profile_state.active_permission_profile() + } + + pub(super) fn apply_permission_profile_to_permissions( + &self, + permissions: &mut crate::config::Permissions, + ) { + permissions.set_permission_profile_state(self.permission_profile_state.clone()); + } + + #[cfg(test)] + pub(super) fn set_permission_profile_for_tests( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile_state + .set_legacy_permission_profile(permission_profile) } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { @@ -117,7 +136,7 @@ impl SessionConfiguration { .unwrap_or_else(|_| { let file_system_sandbox_policy = self.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - self.permission_profile.get(), + self.permission_profile_state.permission_profile(), &file_system_sandbox_policy, self.network_sandbox_policy(), &self.cwd, @@ -126,11 +145,13 @@ impl SessionConfiguration { } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.permission_profile().file_system_sandbox_policy() } pub(super) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.permission_profile_state + .permission_profile() + .network_sandbox_policy() } pub(super) fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { @@ -227,16 +248,16 @@ impl SessionConfiguration { let active_permission_profile = updates.active_permission_profile.clone().or_else(|| { if permission_profile == self.permission_profile() { - self.active_permission_profile.clone() + self.active_permission_profile() } else { None } }); next_configuration.set_permission_profile_projection( permission_profile, + active_permission_profile, Some(¤t_file_system_sandbox_policy), )?; - next_configuration.active_permission_profile = active_permission_profile; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -245,14 +266,15 @@ impl SessionConfiguration { ¤t_file_system_sandbox_policy, ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - next_configuration.permission_profile.set( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ), - )?; - next_configuration.active_permission_profile = None; + next_configuration + .permission_profile_state + .set_legacy_permission_profile( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ), + )?; } else if cwd_changed && file_system_policy_matches_legacy && file_system_policy_has_rebindable_project_root_write @@ -266,13 +288,15 @@ impl SessionConfiguration { &next_configuration.cwd, ¤t_file_system_sandbox_policy, ); - next_configuration.permission_profile.set( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(¤t_sandbox_policy), - &file_system_sandbox_policy, - current_network_sandbox_policy, - ), - )?; + next_configuration + .permission_profile_state + .set_legacy_permission_profile( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(¤t_sandbox_policy), + &file_system_sandbox_policy, + current_network_sandbox_policy, + ), + )?; } if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); @@ -286,6 +310,7 @@ impl SessionConfiguration { fn set_permission_profile_projection( &mut self, permission_profile: PermissionProfile, + active_permission_profile: Option, preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>, ) -> ConstraintResult<()> { let enforcement = permission_profile.enforcement(); @@ -301,8 +326,11 @@ impl SessionConfiguration { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.set(effective_permission_profile)?; - Ok(()) + self.permission_profile_state.set_active_permission_profile( + effective_permission_profile, + active_permission_profile, + Vec::new(), + ) } } @@ -756,7 +784,7 @@ impl Session { let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( spec, current_exec_policy.as_ref(), - config.permissions.permission_profile().get(), + config.permissions.permission_profile(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, @@ -818,10 +846,12 @@ impl Session { // before any MCP-related events. It is reasonable to consider // changing this to use Option or OnceCell, though the current // setup is straightforward enough and performs well. - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - config.permissions.permission_profile(), - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -926,7 +956,9 @@ impl Session { initial_messages, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( - session_configuration.permission_profile.get(), + session_configuration + .permission_profile_state() + .permission_profile(), ) }), rollout_path, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 50632ce81a..81012419cb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -869,8 +869,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow state.session_configuration.original_config_do_not_use = Arc::new(config); state .session_configuration - .permission_profile - .set(PermissionProfile::from_legacy_sandbox_policy( + .set_permission_profile_for_tests(PermissionProfile::from_legacy_sandbox_policy( &initial_policy, )) .expect("test setup should allow permission profile"); @@ -2168,9 +2167,9 @@ async fn session_permission_profile_materializes_runtime_workspace_roots() -> an }) .build() .await?; - let session_permission_profile = session_permission_profile_from_config(&config)?; - let file_system_policy = session_permission_profile - .get() + let session_permission_profile_state = session_permission_profile_state_from_config(&config)?; + let file_system_policy = session_permission_profile_state + .permission_profile() .file_system_sandbox_policy(); assert!( @@ -2915,8 +2914,7 @@ async fn set_rate_limits_retains_previous_credits() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3019,8 +3017,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3492,8 +3489,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -3556,13 +3552,15 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd }, ]); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ), + ) + .expect("set permission profile"); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -3597,13 +3595,15 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ ); existing_file_system_policy.glob_scan_max_depth = Some(2); existing_file_system_policy.entries.push(deny_entry.clone()); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy), - &existing_file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy), + &existing_file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + ) + .expect("set permission profile"); let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, @@ -3778,13 +3778,15 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ &sandbox_policy, &session_configuration.cwd, ); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(&sandbox_policy), - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&sandbox_policy), + ), + ) + .expect("set permission profile"); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -3830,13 +3832,15 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up access: FileSystemAccessMode::Write, }, ]); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::Managed, - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ), + ) + .expect("set permission profile"); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -4025,8 +4029,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4134,8 +4137,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4179,10 +4181,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - config.permissions.permission_profile(), - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -4366,8 +4370,7 @@ async fn make_session_with_config_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -4469,8 +4472,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -5986,8 +5988,7 @@ where compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile().clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), codex_home: config.codex_home.clone(), @@ -6031,10 +6032,12 @@ where ); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - config.permissions.permission_profile(), - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 45dc06c776..eefa6bc9da 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -438,12 +438,8 @@ impl Session { per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; - per_turn_config - .permissions - .set_constrained_permission_profile_with_active_profile( - session_configuration.permission_profile.clone(), - session_configuration.active_permission_profile.clone(), - ); + session_configuration + .apply_permission_profile_to_permissions(&mut per_turn_config.permissions); let permission_profile = session_configuration.permission_profile(); let resolved_web_search_mode = resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 4fc6db14e4..c6a0007f13 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3900,7 +3900,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { fn pick_allowed_sandbox_policy( - constraint: &crate::config::Constrained, + permissions: &crate::config::Permissions, base: SandboxPolicy, cwd: &std::path::Path, ) -> SandboxPolicy { @@ -3915,16 +3915,9 @@ async fn build_agent_spawn_config_uses_turn_context_values() { if *candidate == base { return false; } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(candidate, cwd); - let network_sandbox_policy = NetworkSandboxPolicy::from(candidate); - let permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(candidate), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - constraint.can_set(&permission_profile).is_ok() + permissions + .can_set_legacy_sandbox_policy(candidate, cwd) + .is_ok() }) .unwrap_or(base) } @@ -3948,7 +3941,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( - turn.config.permissions.permission_profile(), + &turn.config.permissions, turn.config.legacy_sandbox_policy(), turn_cwd.as_path(), ); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b4724edd29..a71ca5fa3f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -760,7 +760,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile().get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile()) { event_processor.process_warning(message); } diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index b8b7b0a519..313971afdc 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -174,7 +174,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R permissions: Permissions::from_approval_and_profile( Constrained::allow_any(AskForApproval::Never), Constrained::allow_any(PermissionProfile::read_only()), - ), + )?, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 807663189d..d77e3b3140 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -301,7 +301,7 @@ impl App { } let permission_profile_override_value = permission_profile_override .is_some() - .then(|| self.config.permissions.permission_profile().get().clone()); + .then(|| self.config.permissions.permission_profile().clone()); if let Some(permission_profile) = permission_profile_override_value.as_ref() && let Err(err) = self .chat_widget diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index fb351991b6..811c24cc4d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1426,7 +1426,7 @@ impl App { return Ok(AppRunControl::Continue); } self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile().get().clone()); + Some(self.config.permissions.permission_profile().clone()); self.sync_active_thread_permission_settings_to_cached_session() .await; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index e2b3477756..802cda3f80 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -67,7 +67,7 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { let Some(message) = - codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile().get()) + codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile()) else { return; }; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index abdde1c995..25269deadf 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1639,8 +1639,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< app.chat_widget .config_ref() .permissions - .permission_profile() - .get(), + .permission_profile(), &auto_review.permission_profile ); assert_eq!( @@ -1817,8 +1816,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review app.chat_widget .config_ref() .permissions - .permission_profile() - .get(), + .permission_profile(), &auto_review.permission_profile ); assert_eq!( @@ -3004,7 +3002,6 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { .config_ref() .permissions .permission_profile() - .get() .clone(); assert_eq!( session.permission_profile, expected_permission_profile, @@ -3140,7 +3137,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { fork_config.model_reasoning_effort, fork_config.service_tier.as_deref(), fork_config.permissions.approval_policy.value(), - fork_config.permissions.permission_profile().get(), + fork_config.permissions.permission_profile(), fork_config.approvals_reviewer, ), ( diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index d057baa40c..6afbc2de38 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -20,7 +20,6 @@ impl App { .config_ref() .permissions .permission_profile() - .get() .clone(); let active_permission_profile = self .chat_widget @@ -103,7 +102,6 @@ impl App { .config_ref() .permissions .permission_profile() - .get() .clone() } @@ -355,12 +353,11 @@ mod tests { .config_ref() .permissions .permission_profile() - .get() .clone(); assert_eq!(session.permission_profile, expected_permission_profile); assert_ne!( session.permission_profile, - app.config.permissions.permission_profile().get().clone(), + app.config.permissions.permission_profile().clone(), "thread/read fallback must use the active widget permissions rather than stale app \ config defaults" ); diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index cf341cb829..6f2e1c4dbd 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -17,7 +17,7 @@ impl ChatWidget { let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile().get().clone(); + let current_permission_profile = self.config.permissions.permission_profile().clone(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 58cbe9bb73..932263bcc1 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -66,18 +66,25 @@ impl ChatWidget { let permission_sync = self .config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( session.permission_profile.clone(), session.active_permission_profile.clone(), ); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - self.config + if let Err(replace_err) = self + .config .permissions - .set_constrained_permission_profile_with_active_profile( + .replace_permission_profile_from_session_snapshot( Constrained::allow_only(session.permission_profile.clone()), session.active_permission_profile.clone(), + ) + { + tracing::error!( + %replace_err, + "failed to replace permissions from SessionConfigured after constraint fallback" ); + } } self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index e6c8fcad88..6e3ead97bb 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -293,7 +293,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.set_permission_profile(updated_profile.clone()) .expect("set permission profile"); assert_eq!( - chat.config_ref().permissions.permission_profile().get(), + chat.config_ref().permissions.permission_profile(), &updated_profile, "local permission changes should replace SessionConfigured canonical permissions" ); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index e69f2da3c8..65a390a007 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -306,7 +306,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::read_only(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_READ_ONLY, @@ -335,7 +335,7 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() .with_additional_writable_roots(config.cwd.as_path(), std::slice::from_ref(&extra_root)); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::from_runtime_permissions( &file_system_policy, NetworkSandboxPolicy::Restricted, @@ -363,7 +363,7 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, @@ -389,7 +389,7 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, @@ -415,7 +415,7 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { let extra_root = test_path_buf("/workspace/extra").abs(); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write_with( std::slice::from_ref(&extra_root), NetworkSandboxPolicy::Restricted, @@ -451,7 +451,7 @@ async fn status_permissions_workspace_roots_show_additional_directories() { .set_workspace_roots(config.workspace_roots.clone()); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write(), Some(ActivePermissionProfile::new(":workspace")), ) @@ -474,7 +474,7 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { .expect("set approval policy"); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Enabled, @@ -499,7 +499,7 @@ async fn status_permissions_user_defined_profile_shows_name() { let mut config = test_config(&temp_home).await; config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::read_only(), Some(ActivePermissionProfile::new("locked")), ) @@ -519,7 +519,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::read_only(), Some(ActivePermissionProfile::new("locked")), ) @@ -617,7 +617,7 @@ async fn status_snapshot_shows_auto_review_permissions() { config.approvals_reviewer = ApprovalsReviewer::AutoReview; config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( PermissionProfile::workspace_write(), Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, From 3a23e87e203a0cebb57ee1aebf654b460c1e4e1d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 14 May 2026 18:51:36 -0700 Subject: [PATCH 18/51] tui: recover local state db startup failures (#22734) ## Why #22580 made app-server startup fail when the local SQLite state database cannot be initialized. Embedded/local TUI startup still continued on the permissive path, which left the CLI inconsistent and could hide a real startup problem behind unrelated UI. This brings local TUI startup onto the same fail-closed behavior while keeping recovery humane for the two failure modes we are seeing in practice: damaged database files and startup stalls caused by another process holding the database write lock. ## What changed - Embedded TUI startup now uses `state_db::try_init(...)` and returns a typed `LocalStateDbStartupError` that preserves the affected database path plus the underlying failure detail. - CLI startup handles that failure before entering the interactive TUI: - lock-contention failures tell users to quit other Codex processes and try again - failures consistent with a broken local database offer a safe repair that backs up Codex-owned SQLite files, rebuilds local database files, and retries startup once - declined or unsuccessful repairs print concise guidance plus technical details - Shared startup error plumbing lives in `tui/src/startup_error.rs`, while CLI recovery policy and focused recovery tests live in `cli/src/state_db_recovery.rs`. ## Verification - `cargo test -p codex-tui embedded_state_db_failure_is_typed_for_cli_recovery` - `cargo test -p codex-cli state_db_recovery` - Manually held an exclusive SQLite lock on `state_5.sqlite` and confirmed the CLI shows lock-specific guidance without offering repair. - Manually exercised the repair path with a deliberately invalid `sqlite_home` and confirmed it backs up the blocking path and resumes startup. --- codex-rs/cli/src/main.rs | 50 ++++++- codex-rs/cli/src/state_db_recovery.rs | 183 +++++++++++++++++++++++ codex-rs/tui/src/cli.rs | 4 +- codex-rs/tui/src/lib.rs | 58 ++++++- codex-rs/tui/src/startup_error.rs | 29 ++++ codex-rs/utils/cli/src/shared_options.rs | 2 +- 6 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 codex-rs/cli/src/state_db_recovery.rs create mode 100644 codex-rs/tui/src/startup_error.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index de5f52f4e7..87ac5d6676 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -52,6 +52,7 @@ mod doctor; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; +mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -59,6 +60,7 @@ use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; use doctor::DoctorCommand; +use state_db_recovery as local_state_db; use codex_config::LoaderOverrides; use codex_core::build_models_manager; @@ -1980,13 +1982,47 @@ async fn run_interactive_tui( }; *slot = Some(auth_token); } - codex_tui::run_main( - interactive, - arg0_paths, - codex_config::LoaderOverrides::default(), - remote_endpoint, - ) - .await + let start_tui = || { + codex_tui::run_main( + interactive.clone(), + arg0_paths.clone(), + codex_config::LoaderOverrides::default(), + remote_endpoint.clone(), + ) + }; + let mut attempted_repair = false; + loop { + let err = match start_tui().await { + Ok(exit_info) => return Ok(exit_info), + Err(err) => err, + }; + let Some(startup_error) = local_state_db::startup_error(&err) else { + return Err(err); + }; + if local_state_db::is_locked(startup_error.detail()) { + local_state_db::print_locked_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if attempted_repair { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if !local_state_db::confirm_repair(startup_error)? { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + + match local_state_db::repair_files(startup_error).await { + Ok(backups) => local_state_db::print_repair_backups(&backups), + Err(repair_err) => { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(format!( + "failed to repair Codex local data automatically: {repair_err}" + ))); + } + } + attempted_repair = true; + } } fn confirm(prompt: &str) -> std::io::Result { diff --git a/codex-rs/cli/src/state_db_recovery.rs b/codex-rs/cli/src/state_db_recovery.rs new file mode 100644 index 0000000000..8db134540a --- /dev/null +++ b/codex-rs/cli/src/state_db_recovery.rs @@ -0,0 +1,183 @@ +//! CLI recovery for local state database startup failures. +//! +//! This keeps user-facing repair and lock-contention handling out of the main +//! CLI dispatch path while preserving the TUI startup error as the boundary type. + +use codex_tui::LocalStateDbStartupError; +use std::path::PathBuf; + +pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) +} + +pub(crate) fn is_locked(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + detail.contains("database is locked") || detail.contains("database is busy") +} + +pub(crate) fn confirm_repair(startup_error: &LocalStateDbStartupError) -> std::io::Result { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Codex can try a safe repair by backing up those files and rebuilding them."); + print_technical_details(startup_error); + crate::confirm("Repair Codex local data now? [y/N]: ") +} + +pub(crate) async fn repair_files( + startup_error: &LocalStateDbStartupError, +) -> std::io::Result> { + let state_db_path = startup_error.state_db_path(); + let sqlite_home = state_db_path.parent().ok_or_else(|| { + std::io::Error::other("state database path does not have a parent directory") + })?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + let repair_suffix = format!("codex-repair-{timestamp}"); + let mut backups = Vec::new(); + + match tokio::fs::metadata(sqlite_home).await { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + backups.push(backup_path(sqlite_home, &repair_suffix).await?); + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) => return Err(err), + } + + let logs_db_path = codex_state::logs_db_path(sqlite_home); + for path in sqlite_paths(state_db_path) + .into_iter() + .chain(sqlite_paths(logs_db_path.as_path())) + { + if tokio::fs::try_exists(path.as_path()).await? { + backups.push(backup_path(path.as_path(), &repair_suffix).await?); + } + } + + if backups.is_empty() { + return Err(std::io::Error::other( + "no repairable Codex local data files were found", + )); + } + + Ok(backups) +} + +pub(crate) fn print_repair_backups(backups: &[PathBuf]) { + eprintln!("Backed up Codex local data before repair:"); + for backup in backups { + eprintln!(" {}", backup.display()); + } + eprintln!("Retrying startup with rebuilt local data..."); +} + +pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Run `codex doctor` to check your setup and get next-step guidance."); + eprintln!("If this keeps happening, share the technical details below when asking for help."); + print_technical_details(startup_error); +} + +pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because another Codex process is using its local data."); + eprintln!("Quit any other copies of Codex that may still be running, then try again."); + print_technical_details(startup_error); +} + +fn sqlite_paths(db_path: &std::path::Path) -> Vec { + let mut wal_path = db_path.as_os_str().to_os_string(); + wal_path.push("-wal"); + let mut shm_path = db_path.as_os_str().to_os_string(); + shm_path.push("-shm"); + vec![ + db_path.to_path_buf(), + PathBuf::from(wal_path), + PathBuf::from(shm_path), + ] +} + +async fn backup_path(path: &std::path::Path, repair_suffix: &str) -> std::io::Result { + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::other(format!( + "cannot create a repair backup name for {}", + path.display() + )) + })?; + let mut sequence = 0; + loop { + let mut backup_name = file_name.to_os_string(); + backup_name.push(format!(".{repair_suffix}.{sequence}.bak")); + let backup_path = path.with_file_name(backup_name); + if !tokio::fs::try_exists(backup_path.as_path()).await? { + tokio::fs::rename(path, backup_path.as_path()).await?; + return Ok(backup_path); + } + sequence += 1; + } +} + +fn print_technical_details(startup_error: &LocalStateDbStartupError) { + eprintln!("Technical details:"); + eprintln!(" Location: {}", startup_error.state_db_path().display()); + eprintln!(" Cause: {}", startup_error.detail()); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn repair_backs_up_owned_database_files() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let state_path = codex_state::state_db_path(temp_dir.path()); + let logs_path = codex_state::logs_db_path(temp_dir.path()); + let state_sidecars = sqlite_paths(state_path.as_path()); + tokio::fs::write(state_path.as_path(), b"state").await?; + tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?; + tokio::fs::write(logs_path.as_path(), b"logs").await?; + + let startup_error = + LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string()); + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 3); + assert!(!tokio::fs::try_exists(state_path.as_path()).await?); + assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?); + assert!(!tokio::fs::try_exists(logs_path.as_path()).await?); + for backup in backups { + assert!(tokio::fs::try_exists(backup.as_path()).await?); + } + Ok(()) + } + + #[tokio::test] + async fn repair_replaces_blocking_sqlite_home_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let sqlite_home = temp_dir.path().join("sqlite-home"); + tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?; + let startup_error = LocalStateDbStartupError::new( + codex_state::state_db_path(sqlite_home.as_path()), + "File exists".to_string(), + ); + + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 1); + assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir()); + assert!(tokio::fs::try_exists(backups[0].as_path()).await?); + Ok(()) + } + + #[test] + fn lock_failures_skip_repair() { + assert!(is_locked("database is locked")); + assert!(is_locked("database is busy")); + assert!(!is_locked("database disk image is malformed")); + } +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 4ec69bf26f..13ddffb14a 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -5,7 +5,7 @@ use codex_utils_cli::ApprovalModeCliArg; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::SharedCliOptions; -#[derive(Parser, Debug)] +#[derive(Parser, Clone, Debug)] #[command(version)] pub struct Cli { /// Optional user prompt to start the session. @@ -89,7 +89,7 @@ impl std::ops::DerefMut for Cli { } } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct TuiSharedCliOptions(SharedCliOptions); impl TuiSharedCliOptions { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c3ec776206..69b6bec0f3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -14,6 +14,7 @@ use crate::legacy_core::format_exec_policy_error_with_source; use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::session_resume::ResolveCwdOutcome; use crate::session_resume::resolve_cwd_for_resume_or_fork; +pub use crate::startup_error::LocalStateDbStartupError; use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; @@ -167,6 +168,7 @@ mod session_state; mod shimmer; mod skills_helpers; mod slash_command; +mod startup_error; mod startup_hooks_review; mod status; mod status_indicator_widget; @@ -312,6 +314,21 @@ pub(crate) enum AppServerTarget { Remote { endpoint: RemoteAppServerEndpoint }, } +async fn init_state_db_for_app_server_target( + config: &Config, + app_server_target: &AppServerTarget, +) -> std::io::Result> { + match app_server_target { + AppServerTarget::Embedded => state_db::try_init(config).await.map(Some).map_err(|err| { + std::io::Error::other(LocalStateDbStartupError::new( + codex_state::state_db_path(config.sqlite_home.as_path()), + err.to_string(), + )) + }), + AppServerTarget::Remote { .. } => Ok(state_db::get_state_db(config).await), + } +} + fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool { let Some(host) = parsed.host_str() else { return false; @@ -509,7 +526,7 @@ pub(crate) async fn start_app_server_for_picker( pub(crate) async fn start_embedded_app_server_for_picker( config: &Config, ) -> color_eyre::Result { - let state_db = state_db::init(config).await; + let state_db = init_state_db_for_app_server_target(config, &AppServerTarget::Embedded).await?; start_app_server_for_picker( config, &AppServerTarget::Embedded, @@ -989,10 +1006,7 @@ pub async fn run_main( otel.as_ref(), otel_originator.as_str(), ); - let state_db = match &app_server_target { - AppServerTarget::Embedded => state_db::init(&config).await, - AppServerTarget::Remote { .. } => state_db::get_state_db(&config).await, - }; + let state_db = init_state_db_for_app_server_target(&config, &app_server_target).await?; let effective_toml = config.config_layer_stack.effective_config(); match effective_toml.try_into() { @@ -1823,7 +1837,8 @@ mod tests { async fn start_test_embedded_app_server( config: Config, ) -> color_eyre::Result { - let state_db = state_db::init(&config).await; + let state_db = + init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; start_embedded_app_server( Arg0DispatchPaths::default(), config, @@ -2416,6 +2431,37 @@ mod tests { ); Ok(()) } + + #[tokio::test] + async fn embedded_state_db_failure_is_typed_for_cli_recovery() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); + std::fs::write(&occupied_sqlite_home, "occupied")?; + config.sqlite_home = occupied_sqlite_home.clone(); + + let err = + match init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await { + Ok(_) => panic!("embedded startup should surface state db init failures"), + Err(err) => err, + }; + let startup_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .expect("state db startup failure should retain its typed context"); + + assert_eq!( + startup_error.state_db_path(), + codex_state::state_db_path(occupied_sqlite_home.as_path()).as_path() + ); + assert!( + startup_error + .detail() + .contains("failed to initialize state runtime"), + "startup error should preserve the underlying state db failure" + ); + Ok(()) + } #[tokio::test] #[serial] async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { diff --git a/codex-rs/tui/src/startup_error.rs b/codex-rs/tui/src/startup_error.rs new file mode 100644 index 0000000000..55b691d836 --- /dev/null +++ b/codex-rs/tui/src/startup_error.rs @@ -0,0 +1,29 @@ +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +#[error( + "failed to initialize sqlite state db at {}: {detail}", + state_db_path.display() +)] +pub struct LocalStateDbStartupError { + state_db_path: PathBuf, + detail: String, +} + +impl LocalStateDbStartupError { + pub fn new(state_db_path: PathBuf, detail: String) -> Self { + Self { + state_db_path, + detail, + } + } + + pub fn state_db_path(&self) -> &Path { + self.state_db_path.as_path() + } + + pub fn detail(&self) -> &str { + self.detail.as_str() + } +} diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index 8735cb6db8..b59a3b5c85 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -5,7 +5,7 @@ use clap::Args; use codex_protocol::config_types::ProfileV2Name; use std::path::PathBuf; -#[derive(Args, Debug, Default)] +#[derive(Args, Clone, Debug, Default)] pub struct SharedCliOptions { /// Optional image(s) to attach to the initial prompt. #[arg( From 4f2918dd7fa3ad5a642807d3ec41add0c8ce984b Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Thu, 14 May 2026 19:34:21 -0700 Subject: [PATCH 19/51] [codex] Add opaque desktop config namespace (#22584) ## Summary - reserve an explicit opaque `desktop` namespace in `ConfigToml` - expose `desktop` directly in the app-server v2 `config/read` response - keep `config/value/write` and `config/batchWrite` as the only mutation seam for paths like `desktop.someKey` - regenerate the config/app-server schema outputs and document the new contract ## Why The desktop settings work wants one durable, user-editable home for app-owned preferences in `~/.codex/config.toml`, without forcing Rust to model every individual desktop setting key. This PR is only the enabling Rust/app-server layer. It gives the Electron app a first-class config namespace it can read and write through the existing config APIs, while leaving the actual desktop migration to the app PR. ## Behavior and design notes - **Opaque but explicit:** `desktop` is first-class at the typed config root, while its children remain app-owned and open-ended. - **Strict validation still works:** arbitrary nested `desktop.*` keys are accepted instead of being rejected as unknown config. - **Existing config APIs stay the seam:** `config/read` returns the bag, and dotted writes such as `desktop.someKey` continue to flow through `config/value/write` / `config/batchWrite` rather than a bespoke RPC. - **No new consumer behavior:** Core/TUI do not start depending on desktop preferences. This only preserves and exposes the namespace for callers that intentionally use it. - **Same persistence machinery:** hand-edited `config.toml` keeps using the existing TOML edit/write path; this PR does not introduce a second serializer or side channel. - **TOML-friendly values:** the namespace is intended for ordinary JSON-shaped setting values that map cleanly into TOML: strings, numbers, booleans, arrays, and nested object/table values. This PR does not add special handling for TOML-only edge cases such as datetimes. ## Layering semantics Reads keep using the ordinary effective config pipeline, so `desktop` participates in the same layered `config/read` behavior as the rest of `ConfigToml`. Writes still target user config through the existing config service. ## Why this is the shape The alternative would be teaching Rust about each desktop setting as it is added. That would make ordinary app preferences into a cross-repo change, which is exactly the coupling we want to avoid. This keeps the contract small: 1. Rust owns one opaque `desktop` namespace in `config.toml`. 2. The desktop app owns the schema and meaning of individual keys inside it. 3. The existing config APIs remain the transport and mutation surface. That is the piece the desktop settings PR needs in order to move forward cleanly. ## Verification - `cargo test -p codex-config strict_config_accepts_opaque_desktop_keys` - `cargo test -p codex-core desktop_toml_round_trips_opaque_nested_values` - `cargo test -p codex-core config_schema_matches_fixture` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server --test all desktop_settings` --- .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../schema/json/v2/ConfigReadResponse.json | 7 + .../schema/typescript/v2/Config.ts | 2 +- .../src/protocol/v2/config.rs | 1 + .../src/protocol/v2/tests.rs | 4 + codex-rs/app-server/README.md | 6 +- .../app-server/tests/suite/v2/config_rpc.rs | 154 ++++++++++++++++++ codex-rs/config/src/config_toml.rs | 5 + codex-rs/config/src/strict_config_tests.rs | 15 ++ codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 51 ++++++ 12 files changed, 261 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8bcd2edd6d..603b08d343 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7086,6 +7086,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index a7b8a26007..187650c913 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3475,6 +3475,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c21d146a4a..81364a6f40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -228,6 +228,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 50d652cea5..ba24663e87 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -20,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index ca929b02a9..b46515d811 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -279,6 +279,7 @@ pub struct Config { #[experimental("config/read.apps")] #[serde(default)] pub apps: Option, + pub desktop: Option>, #[serde(default, flatten)] pub additional: HashMap, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index f7041cc721..f32c483ec7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1531,6 +1531,7 @@ fn config_granular_approval_policy_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1564,6 +1565,7 @@ fn config_approvals_reviewer_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1619,6 +1621,7 @@ fn config_nested_profile_granular_approval_policy_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1668,6 +1671,7 @@ fn config_nested_profile_approvals_reviewer_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 19d4fef01a..bd7dd186fe 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -216,11 +216,11 @@ Example with notification opt-out: - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `config/read` — fetch the effective config on disk after resolving config layering. +- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). -- `config/value/write` — write a single config key/value to the user's config.toml on disk. -- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. +- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index f95b9fc6dc..38ba12f95f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -411,6 +411,52 @@ default_tools_approval_mode = "prompt" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_desktop_settings() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" + +[desktop.workspace] +collapsed = true +width = 320 +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + let desktop = config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_project_layers_for_cwd() -> Result<()> { let codex_home = TempDir::new()?; @@ -649,6 +695,50 @@ model = "gpt-old" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_updates_desktop_settings() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config(&temp_dir, "")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "desktop.appearanceTheme".to_string(), + value: json!("dark"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + assert_eq!(write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { let temp_dir = TempDir::new()?; @@ -803,6 +893,70 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config(&tmp_dir, "")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "desktop.selected-avatar-id".to_string(), + value: json!("codex"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "desktop.workspace".to_string(), + value: json!({ + "collapsed": true, + "width": 320, + }), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + fn assert_layers_user_then_optional_system( layers: &[codex_app_server_protocol::ConfigLayer], user_file: AbsolutePathBuf, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 851ec52bfd..72641150dd 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -58,6 +58,7 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; +use serde_json::Value as JsonValue; const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ AMAZON_BEDROCK_PROVIDER_ID, @@ -474,6 +475,10 @@ pub struct ConfigToml { #[serde(default)] pub apps: Option, + /// Opaque desktop settings stored alongside the rest of config.toml. + #[serde(default)] + pub desktop: Option>, + /// OTEL configuration. pub otel: Option, diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs index 4621b6b2e5..4d5a62df25 100644 --- a/codex-rs/config/src/strict_config_tests.rs +++ b/codex-rs/config/src/strict_config_tests.rs @@ -110,3 +110,18 @@ foo = true"#; ) ); } + +#[test] +fn strict_config_accepts_opaque_desktop_keys() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[desktop] +appearanceTheme = "dark" + +[desktop.workspace] +collapsed = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents); + + assert_eq!(error, None); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d802fa4e65..0387b2e401 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4073,6 +4073,12 @@ "description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.", "type": "string" }, + "desktop": { + "additionalProperties": true, + "default": null, + "description": "Opaque desktop settings stored alongside the rest of config.toml.", + "type": "object" + }, "developer_instructions": { "default": null, "description": "Developer instructions inserted as a `developer` role message.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7700ea230e..6412bff01e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4795,6 +4795,57 @@ approval_mode = "approve" ); } +#[test] +fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { + let parsed = toml::from_str::( + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" +recentViews = ["threads", "settings"] + +[desktop.workspace] +collapsed = true +width = 320 +pane = { selected = "console", expanded = false } +"#, + )?; + + let desktop = parsed + .desktop + .as_ref() + .expect("desktop settings should deserialize"); + assert_eq!( + desktop.get("appearanceTheme"), + Some(&serde_json::json!("dark")) + ); + assert_eq!( + desktop.get("selected-avatar-id"), + Some(&serde_json::json!("codex")) + ); + assert_eq!( + desktop.get("recentViews"), + Some(&serde_json::json!(["threads", "settings"])) + ); + assert_eq!( + desktop.get("workspace"), + Some(&serde_json::json!({ + "collapsed": true, + "width": 320, + "pane": { + "selected": "console", + "expanded": false, + }, + })) + ); + + let serialized = toml::to_string(&parsed)?; + let reparsed = toml::from_str::(&serialized)?; + assert_eq!(reparsed.desktop, parsed.desktop); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; From d1235a0a78acb6d3e5f15e3d7f90fb9acf0a211e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 14 May 2026 20:51:08 -0700 Subject: [PATCH 20/51] Prevent Esc from dismissing or rewinding `/side` (#22710) Addresses #22599 ## Why `/side` currently lets `Esc` return to the parent thread. Multiple users reported that this collides with queued-steer UI that also advertises `Esc`, so a timing-sensitive keypress can dismiss an ephemeral side chat instead of sending the queued prompt. After removing that dismissal shortcut, the same `Esc` path could fall through to main-thread backtrack/edit-previous handling, which is not valid for ephemeral side conversations. This keeps `/side` out of both global `Esc` behaviors. ## What changed - Remove `Esc` from the `/side` return shortcut matcher while keeping the existing `Ctrl+C` and `Ctrl+D` behavior. - Update side-conversation hints and blocked-command copy to advertise `Ctrl+C` as the return shortcut. - Rename the reserved `Esc` keymap label to describe backtracking only. - Block backtrack/edit-previous handling while a side conversation is active and report `Editing previous prompts is unavailable in side conversations.` when that path would have fired. - Keep composer-owned `Esc` behavior, such as Vim insert-mode escape, routed locally. - Refresh focused shortcut assertions and TUI snapshots for the updated footer and new side-conversation error message. ## Verification Manually tested `/side` use cases and `Esc`, `Ctrl+C`, `Ctrl+D`. --- codex-rs/tui/src/app/input.rs | 21 +++++++- codex-rs/tui/src/app/platform_actions.rs | 32 ++++-------- codex-rs/tui/src/app/side.rs | 4 +- codex-rs/tui/src/app/tests.rs | 51 ++++++++++++++++++- codex-rs/tui/src/chatwidget/side.rs | 4 ++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 3 +- ...e_context_label_preserves_status_line.snap | 2 +- ...ide_context_label_shows_parent_status.snap | 2 +- codex-rs/tui/src/chatwidget/tests/side.rs | 8 +-- codex-rs/tui/src/keymap.rs | 5 +- ...rejection_reports_unavailable_message.snap | 5 ++ 11 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__side_backtrack_rejection_reports_unavailable_message.snap diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 905f62f86f..f6530cad26 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -5,6 +5,9 @@ use super::*; +const SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE: &str = + "Editing previous prompts is unavailable in side conversations."; + impl App { pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { let editor_cmd = match external_editor::resolve_editor_command() { @@ -197,6 +200,8 @@ impl App { // handles it. if self.should_handle_backtrack_esc(key_event) { self.handle_backtrack_esc_key(tui); + } else if self.should_reject_side_backtrack_esc(key_event) { + self.reject_side_backtrack_esc(); } else { self.chat_widget.handle_key_event(key_event); } @@ -252,11 +257,25 @@ impl App { } pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { - self.chat_widget.is_normal_backtrack_mode() + !self.chat_widget.side_conversation_active() + && self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() && !self.chat_widget.should_handle_vim_insert_escape(key_event) } + pub(super) fn should_reject_side_backtrack_esc(&self, key_event: KeyEvent) -> bool { + self.chat_widget.side_conversation_active() + && self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + && !self.chat_widget.should_handle_vim_insert_escape(key_event) + } + + pub(super) fn reject_side_backtrack_esc(&mut self) { + self.reset_backtrack_state(); + self.chat_widget + .add_error_message(SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE.to_string()); + } + fn app_keymap_shortcuts_available(&self) -> bool { self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() } diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index f19ed0bb60..11289bc2aa 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -54,24 +54,16 @@ fn send_world_writable_scan_failed(tx: &AppEventSender) { } pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool { - match key_event { - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => true, + matches!( + key_event, KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) - && (c.eq_ignore_ascii_case(&'c') || c.eq_ignore_ascii_case(&'d')) => - { - true - } - _ => false, - } + && (c.eq_ignore_ascii_case(&'c') || c.eq_ignore_ascii_case(&'d')) + ) } #[cfg(test)] @@ -79,16 +71,7 @@ mod tests { use super::*; #[test] - fn side_return_shortcuts_match_esc_ctrl_c_and_ctrl_d() { - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Esc, - KeyModifiers::NONE, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Repeat, - ))); + fn side_return_shortcuts_match_ctrl_c_and_ctrl_d() { assert!(side_return_shortcut_matches(KeyEvent::new( KeyCode::Char('c'), KeyModifiers::CONTROL, @@ -105,6 +88,11 @@ mod tests { KeyCode::Char('D'), KeyModifiers::CONTROL, ))); + assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Press, + ))); assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( KeyCode::Esc, KeyModifiers::NONE, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 6414e61047..044cb9b391 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -20,7 +20,7 @@ const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!( "Send a message first, then try /side again." ); const SIDE_ALREADY_OPEN_MESSAGE: &str = - "A side conversation is already open. Press Esc to return before starting another."; + "A side conversation is already open. Press Ctrl+C to return before starting another."; const SIDE_BOUNDARY_PROMPT: &str = r#"Side conversation boundary. Everything before this boundary is inherited history from the parent thread. It is reference context only. It is not your current task. @@ -247,7 +247,7 @@ impl App { if let Some(parent_status) = parent_status { label_parts.push(parent_status.label(parent_is_main).to_string()); } - label_parts.push("Esc to return".to_string()); + label_parts.push("Ctrl+C to return".to_string()); self.chat_widget .set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · ")))); } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 25269deadf..dac290e1a5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3169,7 +3169,9 @@ async fn side_start_block_message_tracks_open_side_conversation() { assert_eq!( app.side_start_block_message(), - Some("A side conversation is already open. Press Esc to return before starting another.") + Some( + "A side conversation is already open. Press Ctrl+C to return before starting another." + ) ); app.side_threads.remove(&side_thread_id); @@ -5248,3 +5250,50 @@ async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { assert!(!app.chat_widget.should_handle_vim_insert_escape(esc)); assert!(app.should_handle_backtrack_esc(esc)); } + +#[tokio::test] +async fn side_conversations_reject_backtrack_esc_without_stealing_vim_insert_escape() { + let mut app = make_test_app().await; + let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); + + app.chat_widget + .set_side_conversation_active(/*active*/ true); + assert!(app.chat_widget.composer_is_empty()); + assert!(!app.should_handle_backtrack_esc(esc)); + assert!(app.should_reject_side_backtrack_esc(esc)); + + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget + .handle_key_event(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('i'), + KeyModifiers::NONE, + )); + + assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); + assert!(!app.should_handle_backtrack_esc(esc)); + assert!(!app.should_reject_side_backtrack_esc(esc)); +} + +#[tokio::test] +async fn side_backtrack_rejection_reports_unavailable_message_snapshot() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.backtrack.primed = true; + + app.reject_side_backtrack_esc(); + + assert!(!app.backtrack.primed); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_app_snapshot!( + "side_backtrack_rejection_reports_unavailable_message", + rendered + ); +} diff --git a/codex-rs/tui/src/chatwidget/side.rs b/codex-rs/tui/src/chatwidget/side.rs index 0d6c2f5a8e..e379fe3606 100644 --- a/codex-rs/tui/src/chatwidget/side.rs +++ b/codex-rs/tui/src/chatwidget/side.rs @@ -25,6 +25,10 @@ impl ChatWidget { self.bottom_pane.set_side_conversation_active(active); } + pub(crate) fn side_conversation_active(&self) -> bool { + self.active_side_conversation + } + pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) { self.bottom_pane.set_side_conversation_context_label(label); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 41989c5174..9d8d49a77c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -32,7 +32,8 @@ struct PreparedSlashCommandArgs { const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = "'/side' is unavailable while code review is running."; -const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first."; +const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = + "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap index 168dca8091..5757bf8dac 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.5 Side from main thread · Esc to return " +" gpt-5.5 Side from main thread · Ctrl+C to return " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap index 1f14076573..a1b8af6052 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.5 default · … Side from main thread · main needs input · Esc to return " +" gpt-5.5 default… Side from main thread · main needs input · Ctrl+C to return " diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 2f2a6d3356..906deb3b56 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -106,7 +106,7 @@ async fn slash_commands_without_side_flag_are_rejected_for_side_threads() { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( - "'/review' is unavailable in side conversations. Press Esc to return to the main thread first." + "'/review' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); @@ -132,7 +132,7 @@ async fn slash_side_is_rejected_for_side_threads() { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( - "'/side' is unavailable in side conversations. Press Esc to return to the main thread first." + "'/side' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); @@ -276,7 +276,7 @@ async fn side_context_label_preserves_status_line_snapshot() { chat.refresh_status_line(); chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · Esc to return".to_string(), + "Side from main thread · Ctrl+C to return".to_string(), )); let width = 80; @@ -297,7 +297,7 @@ async fn side_context_label_shows_parent_status_snapshot() { chat.show_welcome_banner = false; chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · main needs input · Esc to return".to_string(), + "Side from main thread · main needs input · Ctrl+C to return".to_string(), )); let width = 80; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index fbac160967..533e28f3a2 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -1445,10 +1445,7 @@ const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ "fixed.cycle_collaboration_mode", key_hint::shift(KeyCode::Tab), ), - ( - "fixed.return_from_side_or_backtrack", - key_hint::plain(KeyCode::Esc), - ), + ("fixed.backtrack", key_hint::plain(KeyCode::Esc)), ("fixed.previous_agent", key_hint::alt(KeyCode::Left)), ("fixed.next_agent", key_hint::alt(KeyCode::Right)), ("fixed.slash_command", key_hint::plain(KeyCode::Char('/'))), diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_backtrack_rejection_reports_unavailable_message.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_backtrack_rejection_reports_unavailable_message.snap new file mode 100644 index 0000000000..841e0c0f59 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_backtrack_rejection_reports_unavailable_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +■ Editing previous prompts is unavailable in side conversations. From e6a73688105735c73cfc21b16c9701e2c05b7849 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 14 May 2026 21:19:06 -0700 Subject: [PATCH 21/51] TUI: split history cells into focused modules (#22704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `codex-rs/tui/src/history_cell.rs` had become the dumping ground for transcript rendering: the shared trait, common helpers, and the concrete cells for messages, plans, MCP/search, notices, patches, approvals, session chrome, and separators all lived together. That made small transcript changes require reopening a very large file and made ownership less obvious. ## What changed - Replaced the monolithic `history_cell.rs` with a `history_cell/` module tree organized by concern. - Kept the existing `crate::history_cell::*` surface stable through re-exports in `history_cell/mod.rs`. - Moved the existing render coverage into `history_cell/tests.rs`. ## Reviewer notes - This PR is intentionally mechanical in mature — existing code and tests moving into files that match their concern. - The snapshot files under `codex-rs/tui/src/history_cell/snapshots/` moved with the extracted test module. `insta` resolves these unnamed snapshots relative to the source file that declares them, so this is path churn only; snapshot contents were not updated. - The small non-mechanical seam edits are limited to split fallout: sibling-module visibility for shared cell containers, moving approval-specific exec-snippet helpers beside approvals, fixing the separator module path, and keeping a couple of existing test helpers reachable after extraction. --- codex-rs/tui/src/history_cell.rs | 6135 ----------------- codex-rs/tui/src/history_cell/approvals.rs | 360 + codex-rs/tui/src/history_cell/base.rs | 104 + codex-rs/tui/src/history_cell/exec.rs | 242 + codex-rs/tui/src/history_cell/mcp.rs | 780 +++ codex-rs/tui/src/history_cell/messages.rs | 455 ++ codex-rs/tui/src/history_cell/mod.rs | 300 + codex-rs/tui/src/history_cell/notices.rs | 186 + codex-rs/tui/src/history_cell/patches.rs | 93 + codex-rs/tui/src/history_cell/plans.rs | 215 + .../src/history_cell/request_user_input.rs | 187 + codex-rs/tui/src/history_cell/search.rs | 144 + codex-rs/tui/src/history_cell/separators.rs | 171 + codex-rs/tui/src/history_cell/session.rs | 429 ++ ..._tests__active_mcp_tool_call_snapshot.snap | 0 ...__tests__coalesced_reads_dedupe_names.snap | 0 ...coalesces_reads_across_multiple_calls.snap | 0 ...sces_sequential_reads_within_one_call.snap | 0 ...ompleted_mcp_tool_call_error_snapshot.snap | 0 ...call_multiple_outputs_inline_snapshot.snap | 0 ...p_tool_call_multiple_outputs_snapshot.snap | 0 ...pleted_mcp_tool_call_success_snapshot.snap | 0 ...cp_tool_call_wrapped_outputs_snapshot.snap | 0 ...er_policy_error_event_narrow_snapshot.snap | 0 ...ts__cyber_policy_error_event_snapshot.snap | 0 ..._error_event_oversized_input_snapshot.snap | 0 ...tests__mcp_inventory_loading_snapshot.snap | 0 ..._statuses_renders_status_only_servers.snap | 0 ...om_statuses_renders_verbose_inventory.snap | 0 ...sts_tools_for_hyphenated_server_names.snap | 0 ...p_tools_output_masks_sensitive_values.snap | 0 ...both_lines_wrap_with_correct_prefixes.snap | 0 ...ut_wrap_uses_branch_then_eight_spaces.snap | 0 ...with_extra_indent_on_subsequent_lines.snap | 0 ...pdate_with_note_and_wrapping_snapshot.snap | 0 ...ts__plan_update_without_note_snapshot.snap | 0 ...put_chunk_leading_whitespace_snapshot.snap | 0 ...cell__tests__ps_output_empty_snapshot.snap | 0 ...ests__ps_output_long_command_snapshot.snap | 0 ...sts__ps_output_many_sessions_snapshot.snap | 0 ...__tests__ps_output_multiline_snapshot.snap | 0 ...n_cell_multiline_with_stderr_snapshot.snap | 0 ...ll__tests__raw_mode_toggle_transcript.snap | 0 ...s__session_header_indicates_yolo_mode.snap | 0 ...nfo_availability_nux_tooltip_snapshot.snap | 0 ...single_line_command_compact_when_fits.snap | 0 ...nd_wraps_with_four_space_continuation.snap | 0 ...rr_tail_more_than_five_lines_snapshot.snap | 0 ...y_cell_numbers_multiple_remote_images.snap | 0 ...istory_cell_renders_remote_image_urls.snap | 0 ...wraps_and_prefixes_each_line_snapshot.snap | 0 ...sts__web_search_history_cell_snapshot.snap | 0 ...arch_history_cell_transcript_snapshot.snap | 0 codex-rs/tui/src/history_cell/tests.rs | 2502 +++++++ 54 files changed, 6168 insertions(+), 6135 deletions(-) delete mode 100644 codex-rs/tui/src/history_cell.rs create mode 100644 codex-rs/tui/src/history_cell/approvals.rs create mode 100644 codex-rs/tui/src/history_cell/base.rs create mode 100644 codex-rs/tui/src/history_cell/exec.rs create mode 100644 codex-rs/tui/src/history_cell/mcp.rs create mode 100644 codex-rs/tui/src/history_cell/messages.rs create mode 100644 codex-rs/tui/src/history_cell/mod.rs create mode 100644 codex-rs/tui/src/history_cell/notices.rs create mode 100644 codex-rs/tui/src/history_cell/patches.rs create mode 100644 codex-rs/tui/src/history_cell/plans.rs create mode 100644 codex-rs/tui/src/history_cell/request_user_input.rs create mode 100644 codex-rs/tui/src/history_cell/search.rs create mode 100644 codex-rs/tui/src/history_cell/separators.rs create mode 100644 codex-rs/tui/src/history_cell/session.rs rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__mcp_inventory_loading_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__session_header_indicates_yolo_mode.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__session_info_availability_nux_tooltip_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__user_history_cell_numbers_multiple_remote_images.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__user_history_cell_renders_remote_image_urls.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap (100%) rename codex-rs/tui/src/{ => history_cell}/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap (100%) create mode 100644 codex-rs/tui/src/history_cell/tests.rs diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs deleted file mode 100644 index d78d3f86b0..0000000000 --- a/codex-rs/tui/src/history_cell.rs +++ /dev/null @@ -1,6135 +0,0 @@ -//! Transcript/history cells for the Codex TUI. -//! -//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed -//! transcript entries and, transiently, an in-flight active cell that can mutate in place while -//! streaming. -//! -//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and -//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on -//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place -//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the -//! rendered transcript output can change. - -use crate::diff_model::FileChange; -use crate::diff_render::create_diff_summary; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::OutputLinesParams; -use crate::exec_cell::TOOL_CALL_MAX_LINES; -use crate::exec_cell::output_lines; -use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::legacy_core::config::Config; -use crate::live_wrap::take_prefix_by_width; -use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; -use crate::motion::MotionMode; -use crate::motion::ReducedMotionIndicator; -use crate::motion::activity_indicator; -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::session_state::ThreadSessionState; -use crate::style::proposed_plan_style; -use crate::style::user_message_style; -#[cfg(test)] -use crate::test_support::PathBufExt; -#[cfg(test)] -use crate::test_support::test_path_buf; -use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; -use crate::tooltips; -use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; -use crate::wrapping::RtOptions; -use crate::wrapping::adaptive_wrap_line; -use crate::wrapping::adaptive_wrap_lines; -use base64::Engine; -use codex_app_server_protocol::AskForApproval; -use codex_app_server_protocol::McpAuthStatus; -use codex_app_server_protocol::McpServerStatus; -use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; -use codex_app_server_protocol::ToolRequestUserInputAnswer; -use codex_app_server_protocol::ToolRequestUserInputQuestion; -use codex_app_server_protocol::WebSearchAction; -use codex_config::types::McpServerTransportConfig; -#[cfg(test)] -use codex_mcp::qualified_mcp_tool_name_prefix; -use codex_otel::RuntimeMetricsSummary; -use codex_protocol::account::PlanType; -use codex_protocol::approvals::ExecPolicyAmendment; -use codex_protocol::approvals::NetworkPolicyAmendment; -#[cfg(test)] -use codex_protocol::mcp::Resource; -#[cfg(test)] -use codex_protocol::mcp::ResourceTemplate; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::local_image_label_text; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::user_input::TextElement; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::format_env_display; -use image::DynamicImage; -use image::ImageReader; -use ratatui::prelude::*; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Styled; -use ratatui::style::Stylize; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::any::Any; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; -use tracing::error; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; -use url::Url; - -const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; -const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; - -mod hook_cell; - -pub(crate) use hook_cell::HookCell; -pub(crate) use hook_cell::new_active_hook_cell; -pub(crate) use hook_cell::new_completed_hook_cell; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum HistoryRenderMode { - Rich, - Raw, -} - -pub(crate) fn raw_lines_from_source(source: &str) -> Vec> { - if source.is_empty() { - return Vec::new(); - } - - let mut parts = source.split('\n').collect::>(); - if source.ends_with('\n') { - parts.pop(); - } - - parts - .into_iter() - .map(|line| Line::from(line.to_string())) - .collect() -} - -pub(crate) fn plain_lines(lines: impl IntoIterator>) -> Vec> { - lines - .into_iter() - .map(|line| { - let text = line - .spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::(); - Line::from(text) - }) - .collect() -} - -/// A single renderable unit of conversation history. -/// -/// Each cell produces logical `Line`s and reports how many viewport -/// rows those lines occupy at a given terminal width. The default -/// height implementations use `Paragraph::wrap` to account for lines -/// that overflow the viewport width (e.g. long URLs that are kept -/// intact by adaptive wrapping). Concrete types only need to override -/// heights when they apply additional layout logic beyond what -/// `Paragraph::line_count` captures. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { - /// Returns the logical lines for the main chat viewport. - fn display_lines(&self, width: u16) -> Vec>; - - /// Returns copy-friendly plain logical lines for raw scrollback mode. - fn raw_lines(&self) -> Vec>; - - fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { - match mode { - HistoryRenderMode::Rich => self.display_lines(width), - HistoryRenderMode::Raw => self.raw_lines(), - } - } - - /// Returns the number of viewport rows needed to render this cell. - /// - /// The default delegates to `Paragraph::line_count` with - /// `Wrap { trim: false }`, which measures the actual row count after - /// ratatui's viewport-level character wrapping. This is critical - /// for lines containing URL-like tokens that are wider than the - /// terminal — the logical line count would undercount. - fn desired_height(&self, width: u16) -> u16 { - self.desired_height_for_mode(width, HistoryRenderMode::Rich) - } - - fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { - Paragraph::new(Text::from(self.display_lines_for_mode(width, mode))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - /// Returns lines for the transcript overlay (`Ctrl+T`). - /// - /// Defaults to `display_lines`. Override when the transcript - /// representation differs (e.g. `ExecCell` shows all calls with - /// `$`-prefixed commands and exit status). - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) - } - - /// Returns the number of viewport rows for the transcript overlay. - /// - /// Uses the same `Paragraph::line_count` measurement as - /// `desired_height`. Contains a workaround for a ratatui bug where - /// a single whitespace-only line reports 2 rows instead of 1. - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); - // Workaround: ratatui's line_count returns 2 for a single - // whitespace-only line. Clamp to 1 in that case. - if let [line] = &lines[..] - && line - .spans - .iter() - .all(|s| s.content.chars().all(char::is_whitespace)) - { - return 1; - } - - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn is_stream_continuation(&self) -> bool { - false - } - - /// Returns a coarse "animation tick" when transcript output is time-dependent. - /// - /// The transcript overlay caches the rendered output of the in-flight active cell, so cells - /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over - /// time to signal that the cached tail should be recomputed. Returning `None` means the - /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation - /// allows the overlay to keep up with the main viewport. - /// - /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on - /// the first rendered frame even though the main viewport is animating. - fn transcript_animation_tick(&self) -> Option { - None - } -} - -impl Renderable for Box { - fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); - let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - let y = if area.height == 0 { - 0 - } else { - let overflow = paragraph - .line_count(area.width) - .saturating_sub(usize::from(area.height)); - u16::try_from(overflow).unwrap_or(u16::MAX) - }; - // Active-cell content can reflow dramatically during resize/stream updates. Clear the - // entire draw area first so stale glyphs from previous frames never linger. - Clear.render(area, buf); - paragraph.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 - } - - pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[derive(Debug)] -pub(crate) struct UserHistoryCell { - pub message: String, - pub text_elements: Vec, - #[allow(dead_code)] - pub local_image_paths: Vec, - pub remote_image_urls: Vec, -} - -/// Build logical lines for a user message with styled text elements. -/// -/// This preserves explicit newlines while interleaving element spans and skips -/// malformed byte ranges instead of panicking during history rendering. -fn build_user_message_lines_with_elements( - message: &str, - elements: &[TextElement], - style: Style, - element_style: Style, -) -> Vec> { - let mut elements = elements.to_vec(); - elements.sort_by_key(|e| e.byte_range.start); - let mut offset = 0usize; - let mut raw_lines: Vec> = Vec::new(); - for line_text in message.split('\n') { - let line_start = offset; - let line_end = line_start + line_text.len(); - let mut spans: Vec> = Vec::new(); - // Track how much of the line we've emitted to interleave plain and styled spans. - let mut cursor = line_start; - for elem in &elements { - let start = elem.byte_range.start.max(line_start); - let end = elem.byte_range.end.min(line_end); - if start >= end { - continue; - } - let rel_start = start - line_start; - let rel_end = end - line_start; - // Guard against malformed UTF-8 byte ranges from upstream data; skip - // invalid elements rather than panicking while rendering history. - if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { - continue; - } - let rel_cursor = cursor - line_start; - if cursor < start - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..rel_start) - { - spans.push(Span::from(segment.to_string())); - } - if let Some(segment) = line_text.get(rel_start..rel_end) { - spans.push(Span::styled(segment.to_string(), element_style)); - cursor = end; - } - } - let rel_cursor = cursor - line_start; - if cursor < line_end - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..) - { - spans.push(Span::from(segment.to_string())); - } - let line = if spans.is_empty() { - Line::from(line_text.to_string()).style(style) - } else { - Line::from(spans).style(style) - }; - raw_lines.push(line); - // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts - // for the separator byte. - offset = line_end + 1; - } - - raw_lines -} - -fn remote_image_display_line(style: Style, index: usize) -> Line<'static> { - Line::from(local_image_label_text(index)).style(style) -} - -fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { - while lines - .last() - .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) - { - lines.pop(); - } - lines -} - -impl HistoryCell for UserHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let wrap_width = width - .saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ) - .max(1); - - let style = user_message_style(); - let element_style = style.fg(Color::Cyan); - - let wrapped_remote_images = if self.remote_image_urls.is_empty() { - None - } else { - Some(adaptive_wrap_lines( - self.remote_image_urls - .iter() - .enumerate() - .map(|(idx, _url)| { - remote_image_display_line(element_style, idx.saturating_add(1)) - }), - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - )) - }; - - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { - None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); - let wrapped = adaptive_wrap_lines( - message_without_trailing_newlines - .split('\n') - .map(|line| Line::from(line).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - let wrapped = trim_trailing_blank_lines(wrapped); - (!wrapped.is_empty()).then_some(wrapped) - } else { - let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, - style, - element_style, - ); - let wrapped = adaptive_wrap_lines( - raw_lines, - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - let wrapped = trim_trailing_blank_lines(wrapped); - (!wrapped.is_empty()).then_some(wrapped) - }; - - if wrapped_remote_images.is_none() && wrapped_message.is_none() { - return Vec::new(); - } - - let mut lines: Vec> = vec![Line::from("").style(style)]; - - if let Some(wrapped_remote_images) = wrapped_remote_images { - lines.extend(prefix_lines( - wrapped_remote_images, - " ".into(), - " ".into(), - )); - if wrapped_message.is_some() { - lines.push(Line::from("").style(style)); - } - } - - if let Some(wrapped_message) = wrapped_message { - lines.extend(prefix_lines( - wrapped_message, - "› ".bold().dim(), - " ".into(), - )); - } - - lines.push(Line::from("").style(style)); - lines - } - - fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); - if !self.remote_image_urls.is_empty() { - if !lines.is_empty() { - lines.push(Line::from("")); - } - lines.extend( - self.remote_image_urls - .iter() - .enumerate() - .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), - ); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - /// Session cwd used to render local file links inside the reasoning body. - cwd: PathBuf, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - /// Create a reasoning summary cell that will render local file links relative to the session - /// cwd active when the summary was recorded. - pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self { - Self { - _header: header, - content, - cwd: cwd.to_path_buf(), - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - append_markdown( - &self.content, - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2), - Some(self.cwd.as_path()), - &mut lines, - ); - let summary_style = Style::default().dim().italic(); - let summary_lines = lines - .into_iter() - .map(|mut line| { - line.spans = line - .spans - .into_iter() - .map(|span| span.patch_style(summary_style)) - .collect(); - line - }) - .collect::>(); - - adaptive_wrap_lines( - &summary_lines, - RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) - .subsequent_indent(" ".into()), - ) - } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - self.lines(width) - } - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.lines(width) - } - - fn raw_lines(&self) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - raw_lines_from_source(self.content.trim()) - } - } -} - -#[derive(Debug)] -pub(crate) struct AgentMessageCell { - lines: Vec>, - is_first_line: bool, -} - -impl AgentMessageCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for AgentMessageCell { - fn display_lines(&self, width: u16) -> Vec> { - adaptive_wrap_lines( - &self.lines, - RtOptions::new(width as usize) - .initial_indent(if self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }) - .subsequent_indent(" ".into()), - ) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// A consolidated agent message cell that stores raw markdown source and re-renders from it. -/// -/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App` -/// replaces the contiguous run of `AgentMessageCell`s with a single -/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders -/// from source via `append_markdown_agent`, producing correctly-sized tables -/// with box-drawing borders. -/// -/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the -/// session that produced the message. Reusing the current process cwd during reflow would make old -/// transcript content change meaning after a later `/cd` or resumed session. -#[derive(Debug)] -pub(crate) struct AgentMarkdownCell { - markdown_source: String, - cwd: PathBuf, -} - -impl AgentMarkdownCell { - /// Create a finalized source-backed assistant message cell. - /// - /// `markdown_source` must be the raw source accumulated by the stream controller, not already - /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve - /// stale wrapping instead of repairing it. - pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { - Self { - markdown_source, - cwd: cwd.to_path_buf(), - } - } -} - -impl HistoryCell for AgentMarkdownCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(wrap_width) = - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) - else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); - }; - - let mut lines: Vec> = Vec::new(); - // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / - // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( - &self.markdown_source, - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); - prefix_lines(lines, "• ".dim(), " ".into()) - } - - fn raw_lines(&self) -> Vec> { - raw_lines_from_source(&self.markdown_source) - } -} - -/// Transient active-cell representation of the mutable tail of an agent stream. -/// -/// During streaming, lines that have not yet been committed to scrollback because they belong to -/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on -/// every delta and cleared when the stream finalizes. -#[derive(Debug)] -pub(crate) struct StreamingAgentTailCell { - lines: Vec>, - is_first_line: bool, -} - -impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - // Tail lines are already rendered at the controller's current stream width. - // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( - self.lines.clone(), - if self.is_first_line { - "• ".dim() - } else { - " ".into() - }, - " ".into(), - ) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// Transient active-cell representation of the mutable tail of a proposed-plan stream. -/// -/// The controller prepares the full styled plan lines because plan tails need the same header, -/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining -/// preview-only during streaming. -#[derive(Debug)] -pub(crate) struct StreamingPlanTailCell { - lines: Vec>, - is_stream_continuation: bool, -} - -impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { - Self { - lines, - is_stream_continuation, - } - } -} - -impl HistoryCell for StreamingPlanTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - self.is_stream_continuation - } -} - -#[derive(Debug)] -pub(crate) struct PlainHistoryCell { - lines: Vec>, -} - -impl PlainHistoryCell { - pub(crate) fn new(lines: Vec>) -> Self { - Self { lines } - } -} - -impl HistoryCell for PlainHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -#[derive(Debug)] -pub(crate) struct UpdateAvailableHistoryCell { - latest_version: String, - update_action: Option, -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { - Self { - latest_version, - update_action, - } - } -} - -impl HistoryCell for UpdateAvailableHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - use ratatui_macros::line; - use ratatui_macros::text; - let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] - } else { - line![ - "See ", - "https://github.com/openai/codex".cyan().underlined(), - " for installation options." - ] - }; - - let content = text![ - line![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), - ], - update_instruction, - "", - "See full release notes:", - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ]; - - let inner_width = content - .width() - .min(usize::from(width.saturating_sub(4))) - .max(1); - with_border_with_inner_width(content.lines, inner_width) - } - - fn raw_lines(&self) -> Vec> { - let update_instruction = if let Some(update_action) = self.update_action { - format!("Run {} to update.", update_action.command_str()) - } else { - "See https://github.com/openai/codex for installation options.".to_string() - }; - vec![ - Line::from("Update available!"), - Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)), - Line::from(update_instruction), - Line::from(""), - Line::from("See full release notes:"), - Line::from("https://github.com/openai/codex/releases/latest"), - ] - } -} - -#[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } -} - -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - let opts = RtOptions::new(width.max(1) as usize) - .initial_indent(self.initial_prefix.clone()) - .subsequent_indent(self.subsequent_prefix.clone()); - adaptive_wrap_lines(&self.text, opts) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.text.clone().lines) - } -} - -#[derive(Debug)] -pub(crate) struct UnifiedExecInteractionCell { - command_display: Option, - stdin: String, -} - -impl UnifiedExecInteractionCell { - pub(crate) fn new(command_display: Option, stdin: String) -> Self { - Self { - command_display, - stdin, - } - } -} - -impl HistoryCell for UnifiedExecInteractionCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - let wrap_width = width as usize; - let waited_only = self.stdin.is_empty(); - - let mut header_spans = if waited_only { - vec!["• Waited for background terminal".bold()] - } else { - vec!["↳ ".dim(), "Interacted with background terminal".bold()] - }; - if let Some(command) = &self.command_display - && !command.is_empty() - { - header_spans.push(" · ".dim()); - header_spans.push(command.clone().dim()); - } - let header = Line::from(header_spans); - - let mut out: Vec> = Vec::new(); - let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width)); - push_owned_lines(&header_wrapped, &mut out); - - if waited_only { - return out; - } - - let input_lines: Vec> = self - .stdin - .lines() - .map(|line| Line::from(line.to_string())) - .collect(); - - let input_wrapped = adaptive_wrap_lines( - input_lines, - RtOptions::new(wrap_width) - .initial_indent(Line::from(" └ ".dim())) - .subsequent_indent(Line::from(" ".dim())), - ); - out.extend(input_wrapped); - out - } - - fn raw_lines(&self) -> Vec> { - let mut out = Vec::new(); - if self.stdin.is_empty() { - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Waited for background terminal: {command}" - ))); - } else { - out.push(Line::from("Waited for background terminal")); - } - return out; - } - - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Interacted with background terminal: {command}" - ))); - } else { - out.push(Line::from("Interacted with background terminal")); - } - out.extend(raw_lines_from_source(&self.stdin)); - out - } -} - -pub(crate) fn new_unified_exec_interaction( - command_display: Option, - stdin: String, -) -> UnifiedExecInteractionCell { - UnifiedExecInteractionCell::new(command_display, stdin) -} - -#[derive(Debug)] -struct UnifiedExecProcessesCell { - processes: Vec, -} - -impl UnifiedExecProcessesCell { - fn new(processes: Vec) -> Self { - Self { processes } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct UnifiedExecProcessDetails { - pub(crate) command_display: String, - pub(crate) recent_chunks: Vec, -} - -impl HistoryCell for UnifiedExecProcessesCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - - let wrap_width = width as usize; - let max_processes = 16usize; - let mut out: Vec> = Vec::new(); - out.push(vec!["Background terminals".bold()].into()); - out.push("".into()); - - if self.processes.is_empty() { - out.push(" • No background terminals running.".italic().into()); - return out; - } - - let prefix = " • "; - let prefix_width = UnicodeWidthStr::width(prefix); - let truncation_suffix = " [...]"; - let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); - let mut shown = 0usize; - for process in &self.processes { - if shown >= max_processes { - break; - } - let command = &process.command_display; - let (snippet, snippet_truncated) = { - let (first_line, has_more_lines) = match command.split_once('\n') { - Some((first, _)) => (first, true), - None => (command.as_str(), false), - }; - let max_graphemes = 80; - let mut graphemes = first_line.grapheme_indices(true); - if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { - (first_line[..byte_index].to_string(), true) - } else { - (first_line.to_string(), has_more_lines) - } - }; - if wrap_width <= prefix_width { - out.push(Line::from(prefix.dim())); - shown += 1; - continue; - } - let budget = wrap_width.saturating_sub(prefix_width); - let mut needs_suffix = snippet_truncated; - if !needs_suffix { - let (_, remainder, _) = take_prefix_by_width(&snippet, budget); - if !remainder.is_empty() { - needs_suffix = true; - } - } - if needs_suffix && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (truncated, _, _) = take_prefix_by_width(&snippet, available); - out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); - } else { - let (truncated, _, _) = take_prefix_by_width(&snippet, budget); - out.push(vec![prefix.dim(), truncated.cyan()].into()); - } - - let chunk_prefix_first = " ↳ "; - let chunk_prefix_next = " "; - for (idx, chunk) in process.recent_chunks.iter().enumerate() { - let chunk_prefix = if idx == 0 { - chunk_prefix_first - } else { - chunk_prefix_next - }; - let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix); - if wrap_width <= chunk_prefix_width { - out.push(Line::from(chunk_prefix.dim())); - continue; - } - let budget = wrap_width.saturating_sub(chunk_prefix_width); - let (truncated, remainder, _) = take_prefix_by_width(chunk, budget); - if !remainder.is_empty() && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (shorter, _, _) = take_prefix_by_width(chunk, available); - out.push( - vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(), - ); - } else { - out.push(vec![chunk_prefix.dim(), truncated.dim()].into()); - } - } - shown += 1; - } - - let remaining = self.processes.len().saturating_sub(shown); - if remaining > 0 { - let more_text = format!("... and {remaining} more running"); - if wrap_width <= prefix_width { - out.push(Line::from(prefix.dim())); - } else { - let budget = wrap_width.saturating_sub(prefix_width); - let (truncated, _, _) = take_prefix_by_width(&more_text, budget); - out.push(vec![prefix.dim(), truncated.dim()].into()); - } - } - - out - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } -} - -pub(crate) fn new_unified_exec_processes_output( - processes: Vec, -) -> CompositeHistoryCell { - let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); - let summary = UnifiedExecProcessesCell::new(processes); - CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, /*max_graphemes*/ 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -fn non_empty_exec_snippet(command: &[String]) -> Option { - let snippet = exec_snippet(command); - (!snippet.is_empty()).then_some(snippet) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ReviewDecision { - Approved, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment, - }, - ApprovedForSession, - NetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - Denied, - TimedOut, - Abort, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ApprovalDecisionSubject { - Command(Vec), - NetworkAccess { target: String }, -} - -pub fn new_approval_decision_cell( - subject: ApprovalDecisionSubject, - decision: ReviewDecision, - actor: ApprovalDecisionActor, -) -> Box { - use ReviewDecision::*; - use codex_protocol::approvals::NetworkPolicyRuleAction; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " this time".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " this time".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " this time".bold(), - ], - ), - }, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => { - let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim(); - ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex to always run commands that start with ".into(), - snippet, - ], - ) - } - ApprovedForSession => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " every time this session".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " every time this session".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " every time this session".bold(), - ], - ), - }, - NetworkPolicyAmendment { - network_policy_amendment, - } => { - let target = match subject { - ApprovalDecisionSubject::NetworkAccess { target } => target, - ApprovalDecisionSubject::Command(_) => network_policy_amendment.host, - }; - match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "persisted".bold(), - " Codex network access to ".into(), - Span::from(target).dim(), - ], - ), - NetworkPolicyRuleAction::Deny => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "denied".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " and saved that rule".into(), - ], - ), - } - } - Denied => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - let snippet = Span::from(snippet).dim(); - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ApprovalDecisionActor::Guardian => vec![ - "Request ".into(), - "denied".bold(), - " for codex to run ".into(), - snippet, - ], - } - } else { - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " this request".into(), - ], - ApprovalDecisionActor::Guardian => { - vec!["Request ".into(), "denied".bold()] - } - } - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "did not approve".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - TimedOut => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - "Review ".into(), - "timed out".bold(), - " before this request could be approved".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could access ".into(), - Span::from(target).dim(), - ], - ), - }, - Abort => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "canceled".bold(), - " the request to run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - actor.subject().into(), - "canceled".bold(), - " this request".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "canceled".bold(), - " the request for codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalDecisionActor { - User, - Guardian, -} - -impl ApprovalDecisionActor { - fn subject(self) -> &'static str { - match self { - Self::User => "You ", - Self::Guardian => "Auto-reviewer ", - } - } -} - -pub fn new_guardian_denied_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Request ".into(), - "denied".bold(), - " for codex to apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_denied_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "denied".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -pub fn new_guardian_approved_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "approved".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✔ ".green(), " ")) -} - -pub fn new_guardian_timed_out_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Review ".into(), - "timed out".bold(), - " before codex could apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_timed_out_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Review ".into(), - "timed out".bold(), - " before ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -/// Cyan history cell line showing the current review status. -pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![Line::from(message.cyan())], - } -} - -#[derive(Debug)] -pub(crate) struct PatchHistoryCell { - changes: HashMap, - cwd: PathBuf, -} - -impl HistoryCell for PatchHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - create_diff_summary(&self.changes, &self.cwd, width as usize) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(create_diff_summary( - &self.changes, - &self.cwd, - RAW_DIFF_SUMMARY_WIDTH, - )) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from("tool result (image output)")] - } -} - -pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value - -pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { - if width < 4 { - return None; - } - let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); - Some(inner_width) -} - -/// Render `lines` inside a border sized to the widest span in the content. -pub(crate) fn with_border(lines: Vec>) -> Vec> { - with_border_internal(lines, /*forced_inner_width*/ None) -} - -/// Render `lines` inside a border whose inner width is at least `inner_width`. -/// -/// This is useful when callers have already clamped their content to a -/// specific width and want the border math centralized here instead of -/// duplicating padding logic in the TUI widgets themselves. -pub(crate) fn with_border_with_inner_width( - lines: Vec>, - inner_width: usize, -) -> Vec> { - with_border_internal(lines, Some(inner_width)) -} - -fn with_border_internal( - lines: Vec>, - forced_inner_width: Option, -) -> Vec> { - let max_line_width = lines - .iter() - .map(|line| { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum::() - }) - .max() - .unwrap_or(0); - let content_width = forced_inner_width - .unwrap_or(max_line_width) - .max(max_line_width); - - let mut out = Vec::with_capacity(lines.len() + 2); - let border_inner_width = content_width + 2; - out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); - - for line in lines.into_iter() { - let used_width: usize = line - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum(); - let span_count = line.spans.len(); - let mut spans: Vec> = Vec::with_capacity(span_count + 4); - spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); - if used_width < content_width { - spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); - } - spans.push(Span::from(" │").dim()); - out.push(Line::from(spans)); - } - - out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); - - out -} - -/// Return the emoji followed by a hair space (U+200A). -/// Using only the hair space avoids excessive padding after the emoji while -/// still providing a small visual gap across terminals. -pub(crate) fn padded_emoji(emoji: &str) -> String { - format!("{emoji}\u{200A}") -} - -#[derive(Debug)] -struct TooltipHistoryCell { - tip: String, - cwd: PathBuf, -} - -impl TooltipHistoryCell { - fn new(tip: String, cwd: &Path) -> Self { - Self { - tip, - cwd: cwd.to_path_buf(), - } - } -} - -impl HistoryCell for TooltipHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let wrap_width = usize::from(width.max(1)) - .saturating_sub(indent_width) - .max(1); - let mut lines: Vec> = Vec::new(); - append_markdown( - &format!("**Tip:** {}", self.tip), - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); - - prefix_lines(lines, indent.into(), indent.into()) - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from(format!("Tip: {}", self.tip))] - } -} - -#[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) - } - - fn raw_lines(&self) -> Vec> { - self.0.raw_lines() - } -} - -pub(crate) fn new_session_info( - config: &Config, - requested_model: &str, - session: &ThreadSessionState, - is_first_event: bool, - tooltip_override: Option, - auth_plan: Option, - show_fast_status: bool, -) -> SessionInfoCell { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - session.model.clone(), - session.reasoning_effort, - show_fast_status, - config.cwd.to_path_buf(), - CODEX_CLI_VERSION, - ) - .with_yolo_mode(has_yolo_permissions( - session.approval_policy, - &session.permission_profile, - )); - let mut parts: Vec> = vec![Box::new(header)]; - - if is_first_event { - // Help lines below the header (new copy and list) - let help_lines: Vec> = vec![ - " To get started, describe a task or try one of these commands:" - .dim() - .into(), - Line::from(""), - Line::from(vec![ - " ".into(), - "/init".into(), - " - create an AGENTS.md file with instructions for Codex".dim(), - ]), - Line::from(vec![ - " ".into(), - "/status".into(), - " - show current session configuration".dim(), - ]), - Line::from(vec![ - " ".into(), - "/permissions".into(), - " - choose what Codex is allowed to do".dim(), - ]), - Line::from(vec![ - " ".into(), - "/model".into(), - " - choose what model and reasoning effort to use".dim(), - ]), - Line::from(vec![ - " ".into(), - "/review".into(), - " - review any changes and find issues".dim(), - ]), - ]; - - parts.push(Box::new(PlainHistoryCell { lines: help_lines })); - } else { - if config.show_tooltips - && let Some(tooltips) = tooltip_override - .or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status)) - .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) - { - parts.push(Box::new(tooltips)); - } - if requested_model != session.model.as_str() { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {requested_model}").into(), - format!("used: {}", session.model).into(), - ]; - parts.push(Box::new(PlainHistoryCell { lines })); - } - } - - SessionInfoCell(CompositeHistoryCell { parts }) -} - -pub(crate) fn is_yolo_mode(config: &Config) -> bool { - has_yolo_permissions( - AskForApproval::from(config.permissions.approval_policy.value()), - &config.permissions.effective_permission_profile(), - ) -} - -fn has_yolo_permissions( - approval_policy: AskForApproval, - permission_profile: &PermissionProfile, -) -> bool { - let permission_profile = AppServerPermissionProfile::from(permission_profile.clone()); - approval_policy == AskForApproval::Never - && matches!( - permission_profile, - AppServerPermissionProfile::Disabled - | AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - network: PermissionProfileNetworkPermissions { enabled: true }, - } - ) -} - -fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str { - match status { - McpAuthStatus::Unsupported => "Unsupported", - McpAuthStatus::NotLoggedIn => "Not logged in", - McpAuthStatus::BearerToken => "Bearer token", - McpAuthStatus::OAuth => "OAuth", - } -} - -pub(crate) fn new_user_prompt( - message: String, - text_elements: Vec, - local_image_paths: Vec, - remote_image_urls: Vec, -) -> UserHistoryCell { - UserHistoryCell { - message, - text_elements, - local_image_paths, - remote_image_urls, - } -} - -#[derive(Debug)] -pub(crate) struct SessionHeaderHistoryCell { - version: &'static str, - model: String, - model_style: Style, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - yolo_mode: bool, -} - -impl SessionHeaderHistoryCell { - pub(crate) fn new( - model: String, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self::new_with_style( - model, - Style::default(), - reasoning_effort, - show_fast_status, - directory, - version, - ) - } - - pub(crate) fn new_with_style( - model: String, - model_style: Style, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self { - version, - model, - model_style, - reasoning_effort, - show_fast_status, - directory, - yolo_mode: false, - } - } - - pub(crate) fn with_yolo_mode(mut self, yolo_mode: bool) -> Self { - self.yolo_mode = yolo_mode; - self - } - - fn format_directory(&self, max_width: Option) -> String { - Self::format_directory_inner(&self.directory, max_width) - } - - fn format_directory_inner(directory: &Path, max_width: Option) -> String { - let formatted = if let Some(rel) = relativize_to_home(directory) { - if rel.as_os_str().is_empty() { - "~".to_string() - } else { - format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) - } - } else { - directory.display().to_string() - }; - - if let Some(max_width) = max_width { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(formatted.as_str()) > max_width { - return crate::text_formatting::center_truncate_path(&formatted, max_width); - } - } - - formatted - } - - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) - } -} - -impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { - return Vec::new(); - }; - - let make_row = |spans: Vec>| Line::from(spans); - - // Title line rendered inside the box: ">_ OpenAI Codex (vX)" - let title_spans: Vec> = vec![ - Span::from(">_ ").dim(), - Span::from("OpenAI Codex").bold(), - Span::from(" ").dim(), - Span::from(format!("(v{})", self.version)).dim(), - ]; - - const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; - const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const DIR_LABEL: &str = "directory:"; - const PERMISSIONS_LABEL: &str = "permissions:"; - let label_width = if self.yolo_mode { - DIR_LABEL.len().max(PERMISSIONS_LABEL.len()) - } else { - DIR_LABEL.len() - }; - - let model_label = format!( - "{model_label:> = { - let mut spans = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - spans.push(Span::from(" ")); - spans.push(Span::from(reasoning)); - } - if self.show_fast_status { - spans.push(" ".into()); - spans.push(Span::styled("fast", self.model_style.magenta())); - } - spans.push(" ".dim()); - spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); - spans - }; - - let dir_label = format!("{DIR_LABEL: Vec> { - let mut lines = vec![ - Line::from(format!("OpenAI Codex (v{})", self.version)), - Line::from(format!( - "model: {}{}", - self.model, - self.reasoning_label() - .map(|reasoning| format!(" {reasoning}")) - .unwrap_or_default() - )), - Line::from(format!( - "directory: {}", - self.format_directory(/*max_width*/ None) - )), - ]; - if self.yolo_mode { - lines.push(Line::from("permissions: YOLO mode")); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct CompositeHistoryCell { - parts: Vec>, -} - -impl CompositeHistoryCell { - pub(crate) fn new(parts: Vec>) -> Self { - Self { parts } - } -} - -impl HistoryCell for CompositeHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.display_lines(width); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } - - fn raw_lines(&self) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.raw_lines(); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } -} - -#[derive(Debug)] -pub(crate) struct McpToolCallCell { - call_id: String, - invocation: McpInvocation, - start_time: Instant, - duration: Option, - result: Option>, - animations_enabled: bool, -} - -#[derive(Debug, Clone)] -pub(crate) struct McpInvocation { - pub(crate) server: String, - pub(crate) tool: String, - pub(crate) arguments: Option, -} - -impl McpToolCallCell { - pub(crate) fn new( - call_id: String, - invocation: McpInvocation, - animations_enabled: bool, - ) -> Self { - Self { - call_id, - invocation, - start_time: Instant::now(), - duration: None, - result: None, - animations_enabled, - } - } - - pub(crate) fn call_id(&self) -> &str { - &self.call_id - } - - pub(crate) fn complete( - &mut self, - duration: Duration, - result: Result, - ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); - self.duration = Some(duration); - self.result = Some(result); - image_cell - } - - fn success(&self) -> Option { - match self.result.as_ref() { - Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), - Some(Err(_)) => Some(false), - None => None, - } - } - - pub(crate) fn mark_failed(&mut self) { - let elapsed = self.start_time.elapsed(); - self.duration = Some(elapsed); - self.result = Some(Err("interrupted".to_string())); - } - - fn render_content_block(block: &serde_json::Value, width: usize) -> String { - let content = match serde_json::from_value::(block.clone()) { - Ok(content) => content, - Err(_) => { - return format_and_truncate_tool_result( - &block.to_string(), - TOOL_CALL_MAX_LINES, - width, - ); - } - }; - - match content.raw { - rmcp::model::RawContent::Text(text) => { - format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) - } - rmcp::model::RawContent::Image(_) => "".to_string(), - rmcp::model::RawContent::Audio(_) => "