diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a63f11bccd..c7a3504fba 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -88,7 +88,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index c0a5028996..6af66294c8 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -104,7 +104,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1b312ea1e0..05667704d7 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -241,7 +241,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], 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 e124325f01..5820997fb6 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 @@ -208,7 +208,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json index bc84249516..cd52a492e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationParams.json @@ -12,7 +12,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 7d0d26c052..12db241e1a 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -104,7 +104,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json b/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json index b5e472b6ce..beec3a22cc 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/GetUserSavedConfigResponse.json @@ -16,7 +16,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json index 167aee56e9..327ecd23b2 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/NewConversationParams.json @@ -12,7 +12,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json index 5c7f943786..9f14c22084 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json @@ -12,7 +12,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 4b1e908158..dd7606b5e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -104,7 +104,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json index b2fc248663..aa112e7319 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SendUserTurnParams.json @@ -16,7 +16,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index c9fb2c8e38..89ab825d32 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -104,7 +104,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5fb67c22b4..4c571a6c2e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -100,7 +100,7 @@ "type": "string" }, { - "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", + "description": "DEPRECATED: *All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.", "enum": [ "on-failure" ], diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index b461ac4ad9..3b2024f58b 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -81,7 +81,6 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { resets_at, rate_limits: rate_limits.map(Box::new), promo_message, - limit_name: limit_id, }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; @@ -156,6 +155,10 @@ mod tests { ACTIVE_LIMIT_HEADER, http::HeaderValue::from_static("codex_other"), ); + headers.insert( + "x-codex-other-limit-name", + http::HeaderValue::from_static("codex_other"), + ); let body = serde_json::json!({ "error": { "type": "usage_limit_reached", @@ -173,7 +176,46 @@ mod tests { let CodexErr::UsageLimitReached(usage_limit) = err else { panic!("expected CodexErr::UsageLimitReached, got {err:?}"); }; - assert_eq!(usage_limit.limit_name.as_deref(), Some("codex_other")); + assert_eq!( + usage_limit + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.limit_name.as_deref()), + Some("codex_other") + ); + } + + #[test] + fn map_api_error_does_not_fallback_limit_name_to_limit_id() { + let mut headers = HeaderMap::new(); + headers.insert( + ACTIVE_LIMIT_HEADER, + http::HeaderValue::from_static("codex_other"), + ); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!( + usage_limit + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.limit_name.as_deref()), + None + ); } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6592ca3f54..f1edc474c5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1099,6 +1099,14 @@ impl Session { }); } maybe_push_unstable_features_warning(&config, &mut post_session_configured_events); + if config.approval_policy.value() == AskForApproval::OnFailure { + post_session_configured_events.push(Event { + id: "".to_owned(), + msg: EventMsg::Warning(WarningEvent { + message: "`on-failure` approval policy is deprecated and will be removed in a future release. Use `on-request` for interactive approvals or `never` for non-interactive runs.".to_string(), + }), + }); + } let auth = auth.as_ref(); let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index c7d9ffb82d..404fbc5a7a 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -411,18 +411,22 @@ pub struct UsageLimitReachedError { pub(crate) resets_at: Option>, pub(crate) rate_limits: Option>, pub(crate) promo_message: Option, - pub(crate) limit_name: Option, } impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(limit_name) = self.limit_name.as_deref() + if let Some(limit_name) = self + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.limit_name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) && !limit_name.eq_ignore_ascii_case("codex") { return write!( f, - "You've hit your usage limit for {limit_name}.{}", - retry_suffix(self.resets_at.as_ref()) + "You've hit your usage limit for {limit_name}. Switch to another model now,{}", + retry_suffix_after_or(self.resets_at.as_ref()) ); } @@ -704,7 +708,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -822,7 +825,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -837,7 +839,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -852,7 +853,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -871,7 +871,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!( "You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}." @@ -887,7 +886,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -902,7 +900,6 @@ mod tests { resets_at: None, rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; assert_eq!( err.to_string(), @@ -921,7 +918,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!( "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -939,15 +935,18 @@ mod tests { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Plus)), resets_at: Some(resets_at), - rate_limits: Some(Box::new(rate_limit_snapshot())), + rate_limits: Some(Box::new(RateLimitSnapshot { + limit_id: Some("codex_other".to_string()), + limit_name: Some("codex_other".to_string()), + ..rate_limit_snapshot() + })), promo_message: Some( "Visit https://chatgpt.com/codex/settings/usage to purchase more credits" .to_string(), ), - limit_name: Some("codex_other".to_string()), }; let expected = format!( - "You've hit your usage limit for codex_other. Try again at {expected_time}." + "You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}." ); assert_eq!(err.to_string(), expected); }); @@ -964,7 +963,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -1074,7 +1072,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!( "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}." @@ -1095,7 +1092,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -1113,7 +1109,6 @@ mod tests { resets_at: Some(resets_at), rate_limits: Some(Box::new(rate_limit_snapshot())), promo_message: None, - limit_name: None, }; let expected = format!("You've hit your usage limit. Try again at {expected_time}."); assert_eq!(err.to_string(), expected); @@ -1133,7 +1128,6 @@ mod tests { promo_message: Some( "To continue using Codex, start a free trial of today".to_string(), ), - limit_name: None, }; let expected = format!( "You've hit your usage limit. To continue using Codex, start a free trial of today, or try again at {expected_time}." diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6f075e7dc9..764fc4fbba 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -79,6 +79,7 @@ pub mod review_format; pub mod review_prompts; mod thread_manager; pub mod web_search; +pub mod windows_sandbox_read_grants; pub use codex_protocol::protocol::InitialHistory; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index ab980c8b23..363bec3c7e 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -10,6 +10,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; +use std::path::PathBuf; /// Kill switch for the elevated sandbox NUX on Windows. /// @@ -200,6 +201,25 @@ pub fn run_legacy_setup_preflight( ) } +#[cfg(target_os = "windows")] +pub fn run_setup_refresh_with_extra_read_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + extra_read_roots: Vec, +) -> anyhow::Result<()> { + codex_windows_sandbox::run_setup_refresh_with_extra_read_roots( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + extra_read_roots, + ) +} + #[cfg(not(target_os = "windows"))] pub fn run_legacy_setup_preflight( _policy: &SandboxPolicy, @@ -211,6 +231,18 @@ pub fn run_legacy_setup_preflight( anyhow::bail!("legacy Windows sandbox setup is only supported on Windows") } +#[cfg(not(target_os = "windows"))] +pub fn run_setup_refresh_with_extra_read_roots( + _policy: &SandboxPolicy, + _policy_cwd: &Path, + _command_cwd: &Path, + _env_map: &HashMap, + _codex_home: &Path, + _extra_read_roots: Vec, +) -> anyhow::Result<()> { + anyhow::bail!("Windows sandbox read-root refresh is only supported on Windows") +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/windows_sandbox_read_grants.rs b/codex-rs/core/src/windows_sandbox_read_grants.rs new file mode 100644 index 0000000000..8fa843c8b4 --- /dev/null +++ b/codex-rs/core/src/windows_sandbox_read_grants.rs @@ -0,0 +1,97 @@ +use crate::protocol::SandboxPolicy; +use crate::windows_sandbox::run_setup_refresh_with_extra_read_roots; +use anyhow::Result; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +pub fn grant_read_root_non_elevated( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + read_root: &Path, +) -> Result { + if !read_root.is_absolute() { + anyhow::bail!("path must be absolute: {}", read_root.display()); + } + if !read_root.exists() { + anyhow::bail!("path does not exist: {}", read_root.display()); + } + if !read_root.is_dir() { + anyhow::bail!("path must be a directory: {}", read_root.display()); + } + + let canonical_root = dunce::canonicalize(read_root)?; + run_setup_refresh_with_extra_read_roots( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + vec![canonical_root.clone()], + )?; + Ok(canonical_root) +} + +#[cfg(test)] +mod tests { + use super::grant_read_root_non_elevated; + use crate::protocol::SandboxPolicy; + use std::collections::HashMap; + use std::path::Path; + use tempfile::TempDir; + + fn policy() -> SandboxPolicy { + SandboxPolicy::new_workspace_write_policy() + } + + #[test] + fn rejects_relative_path() { + let tmp = TempDir::new().expect("tempdir"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + Path::new("relative"), + ) + .expect_err("relative path should fail"); + assert!(err.to_string().contains("path must be absolute")); + } + + #[test] + fn rejects_missing_path() { + let tmp = TempDir::new().expect("tempdir"); + let missing = tmp.path().join("does-not-exist"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + missing.as_path(), + ) + .expect_err("missing path should fail"); + assert!(err.to_string().contains("path does not exist")); + } + + #[test] + fn rejects_file_path() { + let tmp = TempDir::new().expect("tempdir"); + let file_path = tmp.path().join("file.txt"); + std::fs::write(&file_path, "hello").expect("write file"); + let err = grant_read_root_non_elevated( + &policy(), + tmp.path(), + tmp.path(), + &HashMap::new(), + tmp.path(), + file_path.as_path(), + ) + .expect_err("file path should fail"); + assert!(err.to_string().contains("path must be a directory")); + } +} diff --git a/codex-rs/debug-client/README.md b/codex-rs/debug-client/README.md index 091fc1e6aa..bd310d9056 100644 --- a/codex-rs/debug-client/README.md +++ b/codex-rs/debug-client/README.md @@ -26,7 +26,7 @@ cargo run -p codex-debug-client -- --thread-id thr_123 - `--codex-bin `: path to the `codex` binary (default: `codex`). - `-c, --config key=value`: pass through `--config` overrides to `codex`. - `--thread-id `: resume a thread instead of starting a new one. -- `--approval-policy `: `untrusted`, `on-failure`, `on-request`, `never`. +- `--approval-policy `: `untrusted`, `on-failure` (deprecated), `on-request`, `never`. - `--auto-approve`: auto-approve command/file-change approvals (default: decline). - `--final-only`: only show completed assistant messages and tool items. - `--model `: optional model override for thread start/resume. diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index da415d9deb..293ecb7ecf 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -59,7 +59,7 @@ Request `newConversation` params (subset): - `model`: string model id (e.g. "o3", "gpt-5.1", "gpt-5.1-codex") - `profile`: optional named profile - `cwd`: optional working directory -- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never` +- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` (deprecated) | `never` - `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access` - `config`: map of additional config overrides - `baseInstructions`: optional instruction override diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6097c60e8a..745068b16e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -356,10 +356,12 @@ pub enum AskForApproval { #[strum(serialize = "untrusted")] UnlessTrusted, - /// *All* commands are auto‑approved, but they are expected to run inside a - /// sandbox where network access is disabled and writes are confined to a - /// specific set of paths. If the command fails, it will be escalated to - /// the user to approve execution without a sandbox. + /// DEPRECATED: *All* commands are auto‑approved, but they are expected to + /// run inside a sandbox where network access is disabled and writes are + /// confined to a specific set of paths. If the command fails, it will be + /// escalated to the user to approve execution without a sandbox. + /// Prefer `OnRequest` for interactive runs or `Never` for non-interactive + /// runs. OnFailure, /// The model decides when to ask the user for approval. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4d6fab5d23..2f63688f73 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1826,6 +1826,63 @@ impl App { let _ = preset; } } + AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { + #[cfg(target_os = "windows")] + { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Granting sandbox read access to {path} ..."), + None, + )); + + let policy = self.config.sandbox_policy.get().clone(); + let policy_cwd = self.config.cwd.clone(); + let command_cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let codex_home = self.config.codex_home.clone(); + let tx = self.app_event_tx.clone(); + + tokio::task::spawn_blocking(move || { + let requested_path = PathBuf::from(path); + let event = match codex_core::windows_sandbox_read_grants::grant_read_root_non_elevated( + &policy, + policy_cwd.as_path(), + command_cwd.as_path(), + &env_map, + codex_home.as_path(), + requested_path.as_path(), + ) { + Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted { + path: canonical_path, + error: None, + }, + Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted { + path: requested_path, + error: Some(err.to_string()), + }, + }; + tx.send(event); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = path; + } + } + AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error { + Some(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!("Error: {err}"))); + } + None => { + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Sandbox read access granted for {}", path.display()), + None, + )); + } + }, AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d2168e9619..67db6d7c8b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -218,6 +218,19 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Begin a non-elevated grant of read access for an additional directory. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + BeginWindowsSandboxGrantReadRoot { + path: String, + }, + + /// Result of attempting to grant read access for an additional directory. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + WindowsSandboxGrantReadRootCompleted { + path: PathBuf, + error: Option, + }, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ab388bbbc2..5dc8605f55 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3347,6 +3347,11 @@ impl ChatWidget { // Not supported; on non-Windows this command should never be reachable. }; } + SlashCommand::SandboxReadRoot => { + self.add_error_message( + "Usage: /sandbox-add-read-dir ".to_string(), + ); + } SlashCommand::Experimental => { self.open_experimental_popup(); } @@ -3549,6 +3554,18 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { + let Some((prepared_args, _prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(false) + else { + return; + }; + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxGrantReadRoot { + path: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a1699c37b8..16e767f802 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -17,6 +17,8 @@ pub enum SlashCommand { Permissions, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, + #[strum(serialize = "sandbox-add-read-dir")] + SandboxReadRoot, Experimental, Skills, Review, @@ -84,7 +86,10 @@ impl SlashCommand { SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up default agent sandbox", + SlashCommand::ElevateSandbox => "set up elevated agent sandbox", + SlashCommand::SandboxReadRoot => { + "let sandbox read a directory: /sandbox-add-read-dir " + } SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Apps => "manage apps", @@ -104,7 +109,10 @@ impl SlashCommand { pub fn supports_inline_args(self) -> bool { matches!( self, - SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan + SlashCommand::Review + | SlashCommand::Rename + | SlashCommand::Plan + | SlashCommand::SandboxReadRoot ) } @@ -122,6 +130,7 @@ impl SlashCommand { | SlashCommand::Approvals | SlashCommand::Permissions | SlashCommand::ElevateSandbox + | SlashCommand::SandboxReadRoot | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan @@ -151,6 +160,7 @@ impl SlashCommand { fn is_visible(self) -> bool { match self { + SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), _ => true, } diff --git a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs index 2ba1e3dc59..08b7be6cca 100644 --- a/codex-rs/utils/cli/src/approval_mode_cli_arg.rs +++ b/codex-rs/utils/cli/src/approval_mode_cli_arg.rs @@ -12,9 +12,10 @@ pub enum ApprovalModeCliArg { /// is not in the "trusted" set. Untrusted, - /// Run all commands without asking for user approval. + /// DEPRECATED: Run all commands without asking for user approval. /// Only asks for approval if a command fails to execute, in which case it /// will escalate to the user to ask for un-sandboxed execution. + /// Prefer `on-request` for interactive runs or `never` for non-interactive runs. OnFailure, /// The model decides when to ask the user for approval. diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 3e15c7a272..61077342dc 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -83,6 +83,8 @@ pub use setup::run_elevated_setup; #[cfg(target_os = "windows")] pub use setup::run_setup_refresh; #[cfg(target_os = "windows")] +pub use setup::run_setup_refresh_with_extra_read_roots; +#[cfg(target_os = "windows")] pub use setup::sandbox_dir; #[cfg(target_os = "windows")] pub use setup::sandbox_secrets_dir; diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 0e43d1ba4b..e7f9bee69f 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -61,6 +61,47 @@ pub fn run_setup_refresh( command_cwd: &Path, env_map: &HashMap, codex_home: &Path, +) -> Result<()> { + run_setup_refresh_inner( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + None, + None, + ) +} + +pub fn run_setup_refresh_with_extra_read_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + extra_read_roots: Vec, +) -> Result<()> { + let mut read_roots = gather_read_roots(command_cwd, policy); + read_roots.extend(extra_read_roots); + run_setup_refresh_inner( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + Some(read_roots), + Some(Vec::new()), + ) +} + +fn run_setup_refresh_inner( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + read_roots_override: Option>, + write_roots_override: Option>, ) -> Result<()> { // Skip in danger-full-access. if matches!( @@ -75,8 +116,8 @@ pub fn run_setup_refresh( command_cwd, env_map, codex_home, - None, - None, + read_roots_override, + write_roots_override, ); let payload = ElevationPayload { version: SETUP_VERSION,