diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml index 654dd8d822..96870db171 100644 --- a/.github/workflows/python-sdk-release.yml +++ b/.github/workflows/python-sdk-release.yml @@ -34,8 +34,8 @@ jobs: exit 1 fi - # The pinned runtime currently publishes a musllinux Linux wheel. - # Build in Alpine so release type generation installs that wheel. + # Build in a glibc Linux image so release type generation installs + # the pinned manylinux runtime wheel. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ @@ -46,7 +46,7 @@ jobs: -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/release-tools /tmp/release-tools/bin/python -m pip install build twine uv==0.11.3 diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f0fd6b90fc..0d8cc29697 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,19 +6,6 @@ on: release-lto: required: true type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -134,11 +121,22 @@ jobs: - name: Stage Windows binaries shell: bash run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" + release_dir="target/${{ matrix.target }}/release" + output_dir="$release_dir/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do - cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" - cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb" + pdb_name="${binary//-/_}" + pdb_path="$release_dir/${pdb_name}.pdb" + if [[ ! -f "$pdb_path" ]]; then + pdb_path="$release_dir/${binary}.pdb" + fi + if [[ ! -f "$pdb_path" ]]; then + echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2 + exit 1 + fi + + cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe" + cp "$pdb_path" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries @@ -153,6 +151,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 364fe78902..6b0814dae5 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -865,7 +865,6 @@ jobs: uses: ./.github/workflows/rust-release-windows.yml with: release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit argument-comment-lint-release-assets: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 54ed8dc558..4103a948cd 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,15 +23,15 @@ jobs: run: | set -euo pipefail - # Run inside Alpine so dependency resolution exercises the pinned - # runtime wheel on the same Linux wheel family that CI installs. + # Run inside a glibc Linux image so dependency resolution exercises + # the pinned manylinux runtime wheel that users install. docker run --rm \ --user "$(id -u):$(id -g)" \ -e HOME=/tmp/codex-python-sdk-home \ -e UV_LINK_MODE=copy \ -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ -w "${GITHUB_WORKSPACE}/sdk/python" \ - python:3.12-alpine \ + python:3.12-slim \ sh -euxc ' python -m venv /tmp/uv /tmp/uv/bin/python -m pip install uv==0.11.3 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 27b66293e1..33392db931 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2347,7 +2347,6 @@ dependencies = [ name = "codex-cloud-config" version = "0.0.0" dependencies = [ - "async-trait", "base64 0.22.1", "chrono", "codex-backend-client", @@ -2356,7 +2355,6 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "codex-utils-absolute-path", "hmac 0.12.1", "pretty_assertions", "serde", @@ -2365,7 +2363,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", "tracing", ] diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b3f6ba22ce..7ceda36a6a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -62,6 +62,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -132,6 +133,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::error::CodexErr; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -389,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo sandbox_network_access: true, collaboration_mode: ModeKind::Plan, personality: None, + workspace_kind: None, is_first_turn: true, } } @@ -833,6 +836,7 @@ fn sample_permissions_approval_request(request_id: i64) -> ServerRequest { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "permissions-1".to_string(), + environment_id: None, started_at_ms: 1_000, cwd: test_path_buf("/tmp").abs(), reason: Some("need network".to_string()), @@ -3252,10 +3256,14 @@ fn turn_event_serializes_expected_shape() { sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), + workspace_kind: Some("projectless".to_string()), num_input_images: 2, is_first_turn: true, status: Some(TurnStatus::Completed), turn_error: None, + codex_error_kind: None, + codex_error_subreason: None, + codex_error_http_status_code: None, steer_count: Some(0), total_tool_call_count: None, shell_command_count: None, @@ -3314,10 +3322,14 @@ fn turn_event_serializes_expected_shape() { "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", + "workspace_kind": "projectless", "num_input_images": 2, "is_first_turn": true, "status": "completed", "turn_error": null, + "codex_error_kind": null, + "codex_error_subreason": null, + "codex_error_http_status_code": null, "steer_count": 0, "total_tool_call_count": null, "shell_command_count": null, @@ -3631,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() { ); assert!(payload["event_params"].get("product_client_id").is_none()); assert_eq!(payload["event_params"]["ephemeral"], json!(false)); + assert_eq!(payload["event_params"]["workspace_kind"], json!(null)); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); @@ -3983,6 +3996,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { /*include_token_usage*/ false, ) .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new( + TurnCodexErrorFact::from_codex_err( + "thread-2".to_string(), + "turn-2".to_string(), + &CodexErr::InvalidRequest("unknown turn environment id `env-2`".to_string()), + ), + ))), + &mut out, + ) + .await; reducer .ingest( AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( @@ -3999,6 +4024,18 @@ async fn turn_lifecycle_emits_failed_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("failed")); assert_eq!(payload["event_params"]["turn_error"], json!("badRequest")); + assert_eq!( + payload["event_params"]["codex_error_kind"], + json!("invalid_request") + ); + assert_eq!( + payload["event_params"]["codex_error_subreason"], + json!("unknown turn environment id `env-2`") + ); + assert_eq!( + payload["event_params"]["codex_error_http_status_code"], + json!(null) + ); } #[tokio::test] @@ -4031,6 +4068,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_params"]["status"], json!("interrupted")); assert_eq!(payload["event_params"]["turn_error"], json!(null)); + assert_eq!(payload["event_params"]["codex_error_kind"], json!(null)); } #[tokio::test] diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index fbcfa32dc5..bd0726b28e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -18,6 +18,7 @@ use crate::facts::SkillInvocation; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -256,6 +257,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( + Box::new(fact), + ))); + } + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index fd52fefc1e..61fe75ac27 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -3,6 +3,7 @@ use std::time::Instant; use crate::facts::AcceptedLineFingerprint; use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexErrKind; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -793,10 +794,14 @@ pub(crate) struct CodexTurnEventParams { pub(crate) sandbox_network_access: bool, pub(crate) collaboration_mode: Option<&'static str>, pub(crate) personality: Option, + pub(crate) workspace_kind: Option, pub(crate) num_input_images: usize, pub(crate) is_first_turn: bool, pub(crate) status: Option, pub(crate) turn_error: Option, + pub(crate) codex_error_kind: Option, + pub(crate) codex_error_subreason: Option, + pub(crate) codex_error_http_status_code: Option, pub(crate) steer_count: Option, pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 9a85276712..b056108507 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,6 +15,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::error::CodexErr; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -29,6 +30,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; +const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512; +const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "..."; + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AcceptedLineFingerprint { pub path_hash: String, @@ -81,6 +85,7 @@ pub struct TurnResolvedConfigFact { pub sandbox_network_access: bool, pub collaboration_mode: ModeKind, pub personality: Option, + pub workspace_kind: Option, pub is_first_turn: bool, } @@ -99,6 +104,147 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone)] +pub struct TurnCodexErrorFact { + pub(crate) turn_id: String, + pub(crate) thread_id: String, + pub(crate) error: TurnCodexError, +} + +impl TurnCodexErrorFact { + pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self { + Self { + turn_id, + thread_id, + error: TurnCodexError::from_codex_err(error), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexErrKind { + TurnAborted, + Stream, + ContextWindowExceeded, + ThreadNotFound, + AgentLimitReached, + SessionConfiguredNotFirstEvent, + Timeout, + RequestTimeout, + Spawn, + Interrupted, + UnexpectedStatus, + InvalidRequest, + InvalidImageRequest, + UsageLimitReached, + ServerOverloaded, + CyberPolicy, + ResponseStreamFailed, + ConnectionFailed, + QuotaExceeded, + UsageNotIncluded, + InternalServerError, + RetryLimit, + InternalAgentDied, + Sandbox, + LandlockSandboxExecutableNotProvided, + UnsupportedOperation, + RefreshTokenFailed, + Fatal, + Io, + Json, + #[cfg(target_os = "linux")] + LandlockRuleset, + #[cfg(target_os = "linux")] + LandlockPathFd, + TokioJoin, + EnvVar, +} + +#[derive(Clone)] +pub(crate) struct TurnCodexError { + pub(crate) kind: CodexErrKind, + pub(crate) subreason: Option, + pub(crate) http_status_code: Option, +} + +impl TurnCodexError { + fn from_codex_err(error: &CodexErr) -> Self { + Self { + kind: error.into(), + subreason: match error { + CodexErr::InvalidRequest(message) => { + // InvalidRequest can contain raw provider response bodies, so bound the + // analytics copy without changing the source CodexErr. + let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES { + message.clone() + } else { + let truncated_len = message.floor_char_boundary( + INVALID_REQUEST_SUBREASON_MAX_BYTES + .saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()), + ); + format!( + "{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}", + &message[..truncated_len] + ) + }; + Some(subreason) + } + _ => None, + }, + http_status_code: error.http_status_code_value(), + } + } +} + +impl From<&CodexErr> for CodexErrKind { + fn from(error: &CodexErr) -> Self { + match error { + CodexErr::TurnAborted => CodexErrKind::TurnAborted, + CodexErr::Stream(..) => CodexErrKind::Stream, + CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded, + CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound, + CodexErr::AgentLimitReached { .. } => CodexErrKind::AgentLimitReached, + CodexErr::SessionConfiguredNotFirstEvent => { + CodexErrKind::SessionConfiguredNotFirstEvent + } + CodexErr::Timeout => CodexErrKind::Timeout, + CodexErr::RequestTimeout => CodexErrKind::RequestTimeout, + CodexErr::Spawn => CodexErrKind::Spawn, + CodexErr::Interrupted => CodexErrKind::Interrupted, + CodexErr::UnexpectedStatus(_) => CodexErrKind::UnexpectedStatus, + CodexErr::InvalidRequest(_) => CodexErrKind::InvalidRequest, + CodexErr::InvalidImageRequest() => CodexErrKind::InvalidImageRequest, + CodexErr::UsageLimitReached(_) => CodexErrKind::UsageLimitReached, + CodexErr::ServerOverloaded => CodexErrKind::ServerOverloaded, + CodexErr::CyberPolicy { .. } => CodexErrKind::CyberPolicy, + CodexErr::ResponseStreamFailed(_) => CodexErrKind::ResponseStreamFailed, + CodexErr::ConnectionFailed(_) => CodexErrKind::ConnectionFailed, + CodexErr::QuotaExceeded => CodexErrKind::QuotaExceeded, + CodexErr::UsageNotIncluded => CodexErrKind::UsageNotIncluded, + CodexErr::InternalServerError => CodexErrKind::InternalServerError, + CodexErr::RetryLimit(_) => CodexErrKind::RetryLimit, + CodexErr::InternalAgentDied => CodexErrKind::InternalAgentDied, + CodexErr::Sandbox(_) => CodexErrKind::Sandbox, + CodexErr::LandlockSandboxExecutableNotProvided => { + CodexErrKind::LandlockSandboxExecutableNotProvided + } + CodexErr::UnsupportedOperation(_) => CodexErrKind::UnsupportedOperation, + CodexErr::RefreshTokenFailed(_) => CodexErrKind::RefreshTokenFailed, + CodexErr::Fatal(_) => CodexErrKind::Fatal, + CodexErr::Io(_) => CodexErrKind::Io, + CodexErr::Json(_) => CodexErrKind::Json, + #[cfg(target_os = "linux")] + CodexErr::LandlockRuleset(_) => CodexErrKind::LandlockRuleset, + #[cfg(target_os = "linux")] + CodexErr::LandlockPathFd(_) => CodexErrKind::LandlockPathFd, + CodexErr::TokioJoin(_) => CodexErrKind::TokioJoin, + CodexErr::EnvVar(_) => CodexErrKind::EnvVar, + } + } +} + #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum TurnStatus { @@ -329,6 +475,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index a33ca7b9e3..c227f4daf1 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -38,6 +38,7 @@ pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; +pub use facts::TurnCodexErrorFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f206393476..5b41178530 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -70,6 +70,8 @@ use crate::facts::PluginUsedInput; use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; +use crate::facts::TurnCodexError; +use crate::facts::TurnCodexErrorFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -322,6 +324,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + codex_error: Option, latest_diff: Option, steer_count: usize, tool_counts: TurnToolCounts, @@ -461,6 +464,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnCodexError(input) => { + self.ingest_turn_codex_error(*input); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -606,6 +612,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -630,6 +637,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -639,6 +647,29 @@ impl AnalyticsReducer { self.maybe_emit_turn_event(&turn_id, out).await; } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { + let TurnCodexErrorFact { + turn_id, + thread_id, + error, + } = input; + let turn_state = self.turns.entry(turn_id).or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + codex_error: None, + latest_diff: None, + steer_count: 0, + tool_counts: TurnToolCounts::default(), + }); + turn_state.thread_id.get_or_insert(thread_id); + turn_state.codex_error = Some(error); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, @@ -795,6 +826,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1154,6 +1186,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1175,6 +1208,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -1194,6 +1228,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + codex_error: None, latest_diff: None, steer_count: 0, tool_counts: TurnToolCounts::default(), @@ -2448,9 +2483,11 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode, personality, + workspace_kind, is_first_turn, } = resolved_config; let token_usage = turn_state.token_usage.clone(); + let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { thread_id, session_id: thread_metadata.session_id.clone(), @@ -2479,10 +2516,14 @@ fn codex_turn_event_params( sandbox_network_access, collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)), personality: personality_mode(personality), + workspace_kind, num_input_images, is_first_turn, status: completed.status, turn_error: completed.turn_error, + codex_error_kind: codex_error.map(|error| error.kind), + codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()), + codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code), steer_count: Some(turn_state.steer_count), total_tool_call_count: Some(turn_state.tool_counts.total), shell_command_count: Some(turn_state.tool_counts.shell_command), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 746f0d8448..c75915629c 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; @@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. @@ -406,7 +406,7 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, strict_config: self.strict_config, - cloud_requirements: self.cloud_requirements, + cloud_config_bundle: self.cloud_config_bundle, thread_config_loader, feedback: self.feedback, log_db: self.log_db, @@ -1035,7 +1035,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: Some(state_db), @@ -2199,7 +2199,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, @@ -2240,7 +2240,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), feedback: CodexFeedback::new(), log_db: None, state_db: None, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 337a469890..961e24c2c2 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -285,6 +285,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -319,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 411095f2c2..13bac2e118 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1590,6 +1590,13 @@ "cwd": { "$ref": "#/definitions/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -1998,4 +2005,4 @@ } ], "title": "ServerRequest" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index cdb8f95538..6aa4c5f35e 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 @@ -3783,6 +3783,13 @@ "cwd": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, + "environmentId": { + "default": null, + "type": [ + "null", + "string" + ] + }, "itemId": { "type": "string" }, @@ -19232,4 +19239,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index 509f60923b..d0677f5f0c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -4,7 +4,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, environmentId: string | null, /** * Unix timestamp (in milliseconds) when this approval request started. */ 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 dfc1942276..45fc5c7e14 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -663,6 +663,8 @@ pub struct PermissionsRequestApprovalParams { pub thread_id: String, pub turn_id: String, pub item_id: String, + #[serde(default)] + pub environment_id: Option, /// Unix timestamp (in milliseconds) when this approval request started. #[ts(type = "number")] pub started_at_ms: i64, 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 1e7558363d..1c1e92b36f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -400,6 +400,7 @@ fn permissions_request_approval_uses_request_permission_profile() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "remote", "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", @@ -416,6 +417,7 @@ fn permissions_request_approval_uses_request_permission_profile() { .expect("permissions request should deserialize"); assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!(params.environment_id.as_deref(), Some("remote")); assert_eq!( params.permissions, RequestPermissionProfile { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f3b55337ce..fdc34e524c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1379,7 +1379,7 @@ the client can offer session-scoped and/or persistent approval choices. ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `environmentId` and `cwd` fields identify the environment and directory used to resolve project-root permissions and relative deny globs. ```json { @@ -1389,6 +1389,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "environmentId": "local", "cwd": "/Users/me/project", "reason": "Select a workspace root", "permissions": { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 43e3d305bb..f4a5f3b45f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -761,6 +761,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), + environment_id: request.environment_id.clone(), started_at_ms: request.started_at_ms, cwd: request_cwd.clone(), reason: request.reason, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 11633342b3..fae113154b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,6 +1,6 @@ use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerStack; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; @@ -31,7 +31,7 @@ pub(crate) struct ConfigManager { runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: Arc>, + cloud_config_bundle: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, } @@ -42,7 +42,7 @@ impl ConfigManager { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, ) -> Self { @@ -52,7 +52,7 @@ impl ConfigManager { runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, strict_config, - cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), + cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), } @@ -73,8 +73,8 @@ impl ConfigManager { .unwrap_or_default() } - pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader { - self.cloud_requirements + pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader { + self.cloud_config_bundle .read() .map(|guard| guard.clone()) .unwrap_or_default() @@ -90,17 +90,17 @@ impl ConfigManager { Ok(()) } - pub(crate) fn replace_cloud_requirements_loader( + pub(crate) fn replace_cloud_config_bundle_loader( &self, auth_manager: Arc, chatgpt_base_url: String, ) { let loader = - cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); - if let Ok(mut guard) = self.cloud_requirements.write() { + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone()); + if let Ok(mut guard) = self.cloud_config_bundle.write() { *guard = loader; } else { - warn!("failed to update cloud requirements loader"); + warn!("failed to update cloud config bundle loader"); } } @@ -245,7 +245,7 @@ impl ConfigManager { .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) - .cloud_requirements(self.current_cloud_requirements()) + .cloud_config_bundle(self.current_cloud_config_bundle()) .thread_config_loader(self.current_thread_config_loader()) .build() .await?; @@ -274,8 +274,8 @@ impl ConfigManager { codex_config::ConfigLoadOptions { loader_overrides: self.loader_overrides.clone(), strict_config: self.strict_config, + cloud_config_bundle: self.current_cloud_config_bundle(), }, - self.current_cloud_requirements(), thread_config_loader.as_ref(), ) .await @@ -303,14 +303,14 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Self { Self::new( codex_home, cli_overrides, loader_overrides, /*strict_config*/ false, - cloud_requirements, + cloud_config_bundle, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ) @@ -322,7 +322,7 @@ impl ConfigManager { codex_home, Vec::new(), LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) } } diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index da2437ca17..8deae8b065 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; -use codex_config::CloudRequirementsLoader; -use codex_config::FeatureRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; +use codex_config::test_support::CloudConfigBundleFixture; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; use tempfile::tempdir; #[test] @@ -356,7 +355,7 @@ async fn read_includes_origins_and_layers() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -435,7 +434,7 @@ writable_roots = ["~/code"] tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -475,7 +474,7 @@ async fn write_value_reports_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service @@ -578,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() { tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service .write_value(ConfigValueWriteParams { @@ -618,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro tmp.path().to_path_buf(), vec![], loader_overrides, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); service @@ -648,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let error = service @@ -708,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() { tmp.path().to_path_buf(), vec![], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +"#, + ), ); let error = service @@ -765,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { tmp.path().to_path_buf(), cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let response = service @@ -821,7 +818,7 @@ async fn write_value_reports_managed_override() { tmp.path().to_path_buf(), vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ); let result = service diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index f7072d0fa1..448a389851 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,7 +77,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; @@ -124,8 +124,8 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Whether config API paths should reject unknown config fields. pub strict_config: bool, - /// Preloaded cloud requirements provider. - pub cloud_requirements: CloudRequirementsLoader, + /// Preloaded cloud config bundle provider. + pub cloud_config_bundle: CloudConfigBundleLoader, /// Loader used to fetch typed thread config sources before a thread starts. pub thread_config_loader: Arc, /// Feedback sink used by app-server/core telemetry and logs. @@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { - warn!(error = %err, "Failed to preload config for cloud requirements"); - // TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed. + warn!(error = %err, "Failed to preload config for cloud config bundle"); + // TODO: Decide whether bootstrap config preload failures should block startup. + // If this fails, we cannot install cloud/thread config loaders, so non-strict + // startup may continue without managed cloud config. } }; let mut config_warnings = Vec::new(); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 4ddfa34735..0996c409bc 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -103,7 +103,7 @@ mod tests { use crate::extensions::thread_extensions; use async_trait::async_trait; use codex_arg0::Arg0DispatchPaths; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigContext; use codex_config::ThreadConfigLoadError; @@ -213,7 +213,7 @@ mod tests { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), loader.clone(), ); diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 94b5a1af27..771b1fd128 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -240,7 +240,7 @@ async fn build_test_processor( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), ); diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5d8cb4d794..36008ce0b9 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; 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 17d78fb20b..8ebfc037e1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -586,7 +586,7 @@ impl AccountRequestProcessor { ) .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; self.auth_manager.reload().await; - self.config_manager.replace_cloud_requirements_loader( + self.config_manager.replace_cloud_config_bundle_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), ); @@ -645,7 +645,7 @@ impl AccountRequestProcessor { let auth_manager = thread_manager.auth_manager(); auth_manager.reload().await; config_manager - .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + .replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url); config_manager .sync_default_client_residency_requirement() .await; diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs index 63fe2b3d2c..7e93b63ede 100644 --- a/codex-rs/app-server/src/request_processors/config_errors.rs +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -1,11 +1,11 @@ use super::*; -fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { +fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> { let mut current: Option<&(dyn std::error::Error + 'static)> = err .get_ref() .map(|source| source as &(dyn std::error::Error + 'static)); while let Some(source) = current { - if let Some(cloud_error) = source.downcast_ref::() { + if let Some(cloud_error) = source.downcast_ref::() { return Some(cloud_error); } current = source.source(); @@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme } pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { - let data = cloud_requirements_load_error(err).map(|cloud_error| { + let data = cloud_config_bundle_load_error(err).map(|cloud_error| { let mut data = serde_json::json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": format!("{:?}", cloud_error.code()), "detail": cloud_error.to_string(), }); if let Some(status_code) = cloud_error.status_code() { data["statusCode"] = serde_json::json!(status_code); } - if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth { data["action"] = serde_json::json!("relogin"); } data 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 32dfb1eeb9..b26c2e4df3 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2766,7 +2766,7 @@ impl ThreadRequestProcessor { .as_ref() .or(source_thread.rollout_path.as_ref()); if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path) - && requested_path != active_path + && !path_utils::paths_match_after_normalization(requested_path, active_path) { return Err(invalid_request(format!( "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 8689193ced..6be022aa6f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -54,7 +54,7 @@ mod thread_processor_behavior_tests { use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ToolRequestUserInputParams; - use codex_config::CloudRequirementsLoader; + use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; @@ -508,9 +508,9 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_cloud_requirements_failures_for_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, + fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, Some(401), "Your authentication session could not be refreshed automatically. Please log out and sign in again.", )); @@ -520,7 +520,7 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, @@ -535,7 +535,7 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() { let err = std::io::Error::other("required MCP servers failed to initialize"); let error = config_load_error(&err); @@ -549,11 +549,11 @@ mod thread_processor_behavior_tests { } #[test] - fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, + fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, /*status_code*/ None, - "Failed to load cloud requirements (workspace-managed policies).", + "Failed to load cloud config bundle (workspace-managed policies).", )); let error = config_load_error(&err); @@ -561,9 +561,29 @@ mod thread_processor_behavior_tests { assert_eq!( error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "RequestFailed", - "detail": "Failed to load cloud requirements (workspace-managed policies).", + "detail": "Failed to load cloud config bundle (workspace-managed policies).", + })) + ); + } + + #[test] + fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() { + let err = std::io::Error::other(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudConfigBundle", + "errorCode": "InvalidBundle", + "detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)", })) ); } @@ -595,7 +615,7 @@ mod thread_processor_behavior_tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ ThreadConfigSource::Session(SessionThreadConfig { diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index d54f5edc51..05e3ad1c77 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; @@ -150,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/image_generation.rs b/codex-rs/app-server/tests/suite/v2/image_generation.rs new file mode 100644 index 0000000000..e1f4cfbc9a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/image_generation.rs @@ -0,0 +1,215 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const RESULT: &str = "cG5n"; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { + let call_id = "image-run-1"; + let server = responses::start_mock_server().await; + mount_image_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "image_gen", + "imagegen", + &json!({ + "action": "generate", + "prompt": "paint a blue whale", + }) + .to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Generate an image".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + let completed = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_image_generation_completed(&mut mcp), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let ThreadItem::ImageGeneration { + status, + revised_prompt, + result, + saved_path: Some(saved_path), + .. + } = completed.item + else { + panic!("expected completed image generation item with saved path"); + }; + assert_eq!(status, "completed"); + assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); + assert_eq!(result, RESULT); + assert_eq!(std::fs::read(&saved_path)?, b"png"); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let output = requests[1].function_call_output(call_id); + assert_eq!( + output["output"][0], + json!({ + "type": "input_image", + "image_url": format!("data:image/png;base64,{RESULT}"), + "detail": "high", + }) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + assert!( + !requests[1] + .message_input_texts("developer") + .iter() + .any(|text| text.contains("Generated images are saved to")), + "standalone image generation should not emit the legacy developer-message hint" + ); + + Ok(()) +} + +async fn wait_for_image_generation_completed( + mcp: &mut TestAppServer, +) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notification + .params + .context("item/completed notification should include params")?, + )?; + if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) { + return Ok(completed); + } + } +} + +async fn mount_image_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/images/generations")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "created": 1, + "data": [{"b64_json": RESULT}], + }))) + .expect(1) + .mount(server) + .await; +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "openai-custom" +chatgpt_base_url = "{server_uri}" + +[features] +imagegenext = true + +[model_providers.openai-custom] +name = "OpenAI" +base_url = "{server_uri}/api/codex" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +requires_openai_auth = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 37b7a10d9b..d8ecb88996 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1605983705..950cccebb5 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -17,6 +17,7 @@ mod experimental_feature_list; mod external_agent_config; mod fs; mod hooks_list; +mod image_generation; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 158ebb25d1..016e4c3df8 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_core::config::ConfigBuilder; @@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index afbe62b321..afb1ca0ed1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -510,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { } #[tokio::test] -async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -594,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( fork_err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 17c55a74a3..0a99d30ada 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::config::ConfigBuilder; @@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, @@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c016b38827..d18a405164 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2410,6 +2410,36 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result ) .await??; + #[cfg(windows)] + { + let active_path = thread.path.as_ref().expect("thread should have path"); + let active_path_display = active_path.as_os_str().to_string_lossy(); + let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") { + PathBuf::from(format!(r"\\{path}")) + } else if let Some(path) = active_path_display.strip_prefix(r"\\?\") { + PathBuf::from(path) + } else if let Some(path) = active_path_display.strip_prefix(r"\\") { + PathBuf::from(format!(r"\\?\UNC\{path}")) + } else { + PathBuf::from(format!(r"\\?\{active_path_display}")) + }; + let normalized_resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + path: Some(equivalent_path), + ..Default::default() + }) + .await?; + let normalized_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = + to_response::(normalized_resume_resp)?; + assert_eq!(thread.id, thread_id); + } + let stale_thread_id = Uuid::new_v4().to_string(); let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; @@ -3091,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R } #[tokio::test] -async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -3171,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 3c4a0dba5e..7a7ed5522a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< } #[tokio::test] -async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { +async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/backend-api/wham/config/requirements")) + .and(path("/backend-api/wham/config/bundle")) .respond_with( ResponseTemplate::new(401) .insert_header("content-type", "text/html") @@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { assert_eq!( err.error.data, Some(json!({ - "reason": "cloudRequirements", + "reason": "cloudConfigBundle", "errorCode": "Auth", "action": "relogin", "statusCode": 401, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 09c17bb79f..84fffd19ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; @@ -247,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cli_overrides: Vec::new(), loader_overrides, strict_config: false, - cloud_requirements: CloudRequirementsLoader::default(), + cloud_config_bundle: CloudConfigBundleLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, 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 0c2c7b71cd..9f6a027a75 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { url: "https://example.com/a.png".to_string(), detail: None, }], + responsesapi_client_metadata: Some(HashMap::from([( + "workspace_kind".to_string(), + "projectless".to_string(), + )])), ..Default::default() }) .await?; @@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["model"], "mock-model"); assert_eq!(event["event_params"]["model_provider"], "mock_provider"); assert_eq!(event["event_params"]["sandbox_policy"], "read_only"); + assert_eq!(event["event_params"]["workspace_kind"], "projectless"); assert_eq!(event["event_params"]["ephemeral"], false); assert_eq!(event["event_params"]["thread_source"], "user"); assert_eq!(event["event_params"]["initialization_mode"], "new"); diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 159dba8b84..d9bdc9654e 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,6 +1,5 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigBundleResponse; -use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; @@ -393,23 +392,6 @@ impl Client { self.decode_json::(&url, &ct, &body) } - /// Fetch the managed requirements file from codex-backend. - /// - /// `GET /api/codex/config/requirements` (Codex API style) or - /// `GET /wham/config/requirements` (ChatGPT backend-api style). - pub async fn get_config_requirements_file( - &self, - ) -> std::result::Result { - let url = match self.path_style { - PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url), - PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url), - }; - let req = self.http.get(&url).headers(self.headers()); - let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; - self.decode_json::(&url, &ct, &body) - .map_err(RequestError::from) - } - /// Fetch the selected cloud-managed config bundle from codex-backend. /// /// `GET /api/codex/config/bundle` (Codex API style) or diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 00b5bd3697..250aac3d73 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -7,7 +7,6 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigBundleResponse; -pub use types::ConfigFileResponse; pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 38d9c6838f..40daba2ce5 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,5 +1,4 @@ pub use codex_backend_openapi_models::models::ConfigBundleResponse; -pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::DeliveredConfigToml; pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; diff --git a/codex-rs/cloud-config/Cargo.toml b/codex-rs/cloud-config/Cargo.toml index aeba70a489..6bf58c8399 100644 --- a/codex-rs/cloud-config/Cargo.toml +++ b/codex-rs/cloud-config/Cargo.toml @@ -8,7 +8,6 @@ license.workspace = true workspace = true [dependencies] -async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } @@ -17,14 +16,12 @@ codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } hmac = "0.12.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "sync", "time"] } -toml = { workspace = true } +tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tracing = { workspace = true } [dev-dependencies] diff --git a/codex-rs/cloud-config/src/backend.rs b/codex-rs/cloud-config/src/backend.rs new file mode 100644 index 0000000000..cb99316a70 --- /dev/null +++ b/codex-rs/cloud-config/src/backend.rs @@ -0,0 +1,136 @@ +use codex_backend_client::Client as BackendClient; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_login::CodexAuth; +use std::future::Future; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RetryableFailureKind { + BackendClientInit, + Request { status_code: Option }, +} + +impl RetryableFailureKind { + pub(crate) fn status_code(self) -> Option { + match self { + Self::BackendClientInit => None, + Self::Request { status_code } => status_code, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum BundleRequestError { + Retryable(RetryableFailureKind), + Unauthorized { + status_code: Option, + message: String, + }, +} + +/// Retrieves one cloud config bundle from the backend. +/// +/// Implementations should return the backend-selected bundle exactly as delivered and leave +/// validation, caching, and config/requirements parsing decisions to the service layer. +pub(crate) trait BundleClient: Send + Sync { + fn get_bundle( + &self, + auth: &CodexAuth, + ) -> impl Future> + Send; +} + +pub(crate) struct BackendBundleClient { + base_url: String, +} + +impl BackendBundleClient { + pub(crate) fn new(base_url: String) -> Self { + Self { base_url } + } +} + +impl BundleClient for BackendBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + let client = BackendClient::from_auth(self.base_url.clone(), auth) + .inspect_err(|err| { + tracing::warn!( + error = %err, + "Failed to construct backend client for cloud config bundle" + ); + }) + .map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?; + + let response = client + .get_config_bundle() + .await + .inspect_err(|err| { + tracing::warn!(error = %err, "Failed to fetch cloud config bundle"); + }) + .map_err(|err| { + let status_code = err.status().map(|status| status.as_u16()); + if err.is_unauthorized() { + BundleRequestError::Unauthorized { + status_code, + message: err.to_string(), + } + } else { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code }) + } + })?; + + Ok(bundle_from_response(response)) + } +} + +pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle { + let config_toml = response + .config_toml + .flatten() + .map(|config_toml| *config_toml) + .and_then(|config_toml| config_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(config_fragment_from_delivered) + .collect(); + let requirements_toml = response + .requirements_toml + .flatten() + .map(|requirements_toml| *requirements_toml) + .and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten()) + .unwrap_or_default() + .into_iter() + .map(requirements_fragment_from_delivered) + .collect(); + + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: config_toml, + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: requirements_toml, + }, + } +} + +fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment { + CloudConfigFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} + +fn requirements_fragment_from_delivered( + fragment: DeliveredTomlFragment, +) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: fragment.id, + name: fragment.name, + contents: fragment.contents, + } +} diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs new file mode 100644 index 0000000000..c269460c1f --- /dev/null +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -0,0 +1,68 @@ +use crate::backend::BackendBundleClient; +use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT; +use crate::service::CloudConfigBundleService; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::CloudConfigBundleLoader; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthManager; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::OnceLock; +use tokio::task::JoinHandle; + +fn refresher_task_slot() -> &'static Mutex>> { + static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); + REFRESHER_TASK.get_or_init(|| Mutex::new(None)) +} + +pub fn cloud_config_bundle_loader( + auth_manager: Arc, + chatgpt_base_url: String, + codex_home: PathBuf, +) -> CloudConfigBundleLoader { + let service = CloudConfigBundleService::new( + auth_manager, + Arc::new(BackendBundleClient::new(chatgpt_base_url)), + codex_home, + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let refresh_service = service.clone(); + let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + let refresh_task = + tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); + let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { + tracing::warn!("cloud config bundle refresher task slot was poisoned"); + err.into_inner() + }); + if let Some(existing_task) = refresher_guard.replace(refresh_task) { + existing_task.abort(); + } + CloudConfigBundleLoader::new(async move { + task.await.map_err(|err| { + tracing::error!(error = %err, "Cloud config bundle task failed"); + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Internal, + /*status_code*/ None, + format!("cloud config bundle load failed: {err}"), + ) + })? + }) +} + +pub async fn cloud_config_bundle_loader_for_storage( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: String, +) -> CloudConfigBundleLoader { + let auth_manager = AuthManager::shared( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + ) + .await; + cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) +} diff --git a/codex-rs/cloud-config/src/cache.rs b/codex-rs/cloud-config/src/cache.rs new file mode 100644 index 0000000000..1e68646159 --- /dev/null +++ b/codex-rs/cloud-config/src/cache.rs @@ -0,0 +1,253 @@ +//! Signed on-disk cache for cloud config bundles. +//! +//! The cache is scoped to the authenticated ChatGPT user and account, has a +//! short TTL, and is HMAC-signed so malformed or edited files fail closed. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Utc; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use hmac::Hmac; +use hmac::Mac; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use std::path::Path; +use std::time::Duration; +use thiserror::Error; +use tokio::fs; + +const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1; +pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json"; +const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60); +const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] = + b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e"; +const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] = + &[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY]; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub(super) struct CloudConfigBundleCache { + path: AbsolutePathBuf, +} + +impl CloudConfigBundleCache { + pub(super) fn new(codex_home: AbsolutePathBuf) -> Self { + Self { + path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME), + } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) async fn load( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Result { + let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { + return Err(CacheLoadStatus::AuthIdentityIncomplete); + }; + + let bytes = match fs::read(&self.path).await { + Ok(bytes) => bytes, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); + } + return Err(CacheLoadStatus::CacheFileNotFound); + } + }; + + let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) { + Ok(cache_file) => cache_file, + Err(err) => { + return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); + } + }; + let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { + Some(payload_bytes) => payload_bytes, + None => { + return Err(CacheLoadStatus::CacheParseFailed( + "failed to serialize cache payload".to_string(), + )); + } + }; + if !verify_cache_signature(&payload_bytes, &cache_file.signature) { + return Err(CacheLoadStatus::CacheSignatureInvalid); + } + if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION { + return Err(CacheLoadStatus::CacheVersionUnsupported( + cache_file.signed_payload.version, + )); + } + + let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( + cache_file.signed_payload.chatgpt_user_id.as_deref(), + cache_file.signed_payload.account_id.as_deref(), + ) else { + return Err(CacheLoadStatus::CacheIdentityIncomplete); + }; + + if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { + return Err(CacheLoadStatus::CacheIdentityMismatch); + } + + if cache_file.signed_payload.expires_at <= Utc::now() { + return Err(CacheLoadStatus::CacheExpired); + } + + Ok(cache_file.signed_payload) + } + + pub(super) fn log_load_status(&self, status: &CacheLoadStatus) { + if matches!(status, CacheLoadStatus::CacheFileNotFound) { + return; + } + + let warn = matches!( + status, + CacheLoadStatus::CacheReadFailed(_) + | CacheLoadStatus::CacheParseFailed(_) + | CacheLoadStatus::CacheSignatureInvalid + ); + + if warn { + tracing::warn!(path = %self.path.display(), "{status}"); + } else { + tracing::info!(path = %self.path.display(), "{status}"); + } + } + + pub(super) async fn save( + &self, + chatgpt_user_id: Option, + account_id: Option, + bundle: CloudConfigBundle, + ) -> Result<(), CloudConfigBundleCacheError> { + let now = Utc::now(); + let expires_at = now + .checked_add_signed( + ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL) + .map_err(|_| CloudConfigBundleCacheError)?, + ) + .ok_or(CloudConfigBundleCacheError)?; + let signed_payload = CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: now, + expires_at, + chatgpt_user_id, + account_id, + bundle, + }; + let payload_bytes = + cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?; + let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?, + signed_payload, + }) + .map_err(|_| CloudConfigBundleCacheError)?; + + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + } + + fs::write(&self.path, serialized) + .await + .map_err(|_| CloudConfigBundleCacheError)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub(super) enum CacheLoadStatus { + #[error("Skipping cloud config bundle cache read because auth identity is incomplete.")] + AuthIdentityIncomplete, + #[error("Cloud config bundle cache file not found.")] + CacheFileNotFound, + #[error("Failed to read cloud config bundle cache: {0}.")] + CacheReadFailed(String), + #[error("Failed to parse cloud config bundle cache: {0}.")] + CacheParseFailed(String), + #[error("Cloud config bundle cache failed signature verification.")] + CacheSignatureInvalid, + #[error("Ignoring cloud config bundle cache because cached identity is incomplete.")] + CacheIdentityIncomplete, + #[error("Ignoring cloud config bundle cache for different auth identity.")] + CacheIdentityMismatch, + #[error("Ignoring cloud config bundle cache with unsupported version {0}.")] + CacheVersionUnsupported(u32), + #[error("Cloud config bundle cache expired.")] + CacheExpired, + #[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")] + CacheInvalidBundle, +} + +#[derive(Debug, Error)] +#[error("failed to write cloud config bundle cache")] +pub(super) struct CloudConfigBundleCacheError; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheFile { + pub(super) signed_payload: CloudConfigBundleCacheSignedPayload, + pub(super) signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct CloudConfigBundleCacheSignedPayload { + pub(super) version: u32, + pub(super) cached_at: DateTime, + pub(super) expires_at: DateTime, + pub(super) chatgpt_user_id: Option, + pub(super) account_id: Option, + pub(super) bundle: CloudConfigBundle, +} + +pub(super) fn cache_payload_bytes( + payload: &CloudConfigBundleCacheSignedPayload, +) -> Option> { + serde_json::to_vec(&payload).ok() +} + +pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option { + let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?; + mac.update(payload_bytes); + let signature = mac.finalize().into_bytes(); + Some(BASE64_STANDARD.encode(signature)) +} + +pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { + let signature_bytes = match BASE64_STANDARD.decode(signature) { + Ok(signature_bytes) => signature_bytes, + Err(_) => return false, + }; + + CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS + .iter() + .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) +} + +fn verify_cache_signature_with_key( + payload_bytes: &[u8], + signature_bytes: &[u8], + key: &[u8], +) -> bool { + let mut mac = match HmacSha256::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return false, + }; + mac.update(payload_bytes); + mac.verify_slice(signature_bytes).is_ok() +} + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/cache_tests.rs b/codex-rs/cloud-config/src/cache_tests.rs new file mode 100644 index 0000000000..0416ed7e77 --- /dev/null +++ b/codex-rs/cloud-config/src/cache_tests.rs @@ -0,0 +1,206 @@ +use super::*; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::tempdir; + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } +} + +fn signed_cache_file( + signed_payload: CloudConfigBundleCacheSignedPayload, +) -> CloudConfigBundleCacheFile { + let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes"); + CloudConfigBundleCacheFile { + signature: sign_cache_payload(&payload_bytes).expect("signature"), + signed_payload, + } +} + +fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload { + let cached_at = Utc::now(); + CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at, + expires_at: cached_at + ChronoDuration::minutes(30), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle: test_bundle(), + } +} + +fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) { + std::fs::write( + cache.path(), + serde_json::to_vec_pretty(cache_file).expect("serialize cache"), + ) + .expect("write cache"); +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +#[tokio::test] +async fn save_writes_signed_payload_and_loads_for_matching_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let bundle = test_bundle(); + + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + bundle.clone(), + ) + .await + .expect("save cache"); + + let cache_file: CloudConfigBundleCacheFile = + serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache")) + .expect("parse cache"); + assert!( + cache_file.signed_payload.expires_at + <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) + ); + assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); + assert_eq!( + cache_file, + signed_cache_file(CloudConfigBundleCacheSignedPayload { + version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION, + cached_at: cache_file.signed_payload.cached_at, + expires_at: cache_file.signed_payload.expires_at, + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + bundle, + }) + ); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Ok(cache_file.signed_payload) + ); +} + +#[tokio::test] +async fn load_rejects_missing_request_identity_before_reading_cache_file() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache + .load(/*chatgpt_user_id*/ None, Some("account-12345")) + .await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); + assert_eq!( + cache.load(Some("user-12345"), /*account_id*/ None).await, + Err(CacheLoadStatus::AuthIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_reports_missing_and_malformed_cache_files() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheFileNotFound) + ); + + std::fs::write(cache.path(), "{").expect("write malformed cache"); + assert!(matches!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheParseFailed(_)) + )); +} + +#[tokio::test] +async fn load_rejects_tampered_payload() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut cache_file = signed_cache_file(valid_signed_payload()); + cache_file + .signed_payload + .bundle + .requirements_toml + .enterprise_managed[0] + .contents = "allowed_approval_policies = [\"on-request\"]".to_string(); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheSignatureInvalid) + ); +} + +#[tokio::test] +async fn load_rejects_cache_for_incomplete_or_different_identity() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let cache_file = signed_cache_file(valid_signed_payload()); + write_cache_file(&cache, &cache_file); + + assert_eq!( + cache.load(Some("user-99999"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityMismatch) + ); + + let mut signed_payload = valid_signed_payload(); + signed_payload.chatgpt_user_id = None; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheIdentityIncomplete) + ); +} + +#[tokio::test] +async fn load_rejects_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1); + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheExpired) + ); +} + +#[tokio::test] +async fn load_rejects_unsupported_cache_version() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + let mut signed_payload = valid_signed_payload(); + signed_payload.version = 2; + write_cache_file(&cache, &signed_cache_file(signed_payload)); + + assert_eq!( + cache.load(Some("user-12345"), Some("account-12345")).await, + Err(CacheLoadStatus::CacheVersionUnsupported(2)) + ); +} diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index dc9a3689d1..0e742f5522 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -1,2489 +1,14 @@ -//! Cloud-hosted config requirements for Codex. +//! Cloud-hosted configuration data for Codex. //! -//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it -//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT -//! customers. -//! -//! Fetching fails closed for eligible ChatGPT Business and Enterprise accounts. When cloud -//! requirements cannot be loaded for those accounts, Codex fails configuration loading rather than -//! continuing without them. - -use async_trait::async_trait; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Utc; -use codex_backend_client::Client as BackendClient; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; -use codex_config::types::AuthCredentialsStoreMode; -use codex_core::util::backoff; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use codex_login::RefreshTokenError; -use codex_protocol::account::PlanType; -use codex_utils_absolute_path::AbsolutePathBufGuard; -use hmac::Hmac; -use hmac::Mac; -use serde::Deserialize; -use serde::Serialize; -use sha2::Sha256; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::OnceLock; -use std::time::Duration; -use std::time::Instant; -use thiserror::Error; -use tokio::fs; -use tokio::task::JoinHandle; -use tokio::time::sleep; -use tokio::time::timeout; - -const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); -const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; -const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; -const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); -const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); -const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; -const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; -const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; -const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = - "Failed to load cloud requirements (workspace-managed policies)."; -const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( - "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", - "Please contact your workspace admin." -); -const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( - "Your authentication session could not be refreshed automatically. ", - "Please log out and sign in again." -); -const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = - b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; -const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = - &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; - -type HmacSha256 = Hmac; - -fn refresher_task_slot() -> &'static Mutex>> { - static REFRESHER_TASK: OnceLock>>> = OnceLock::new(); - REFRESHER_TASK.get_or_init(|| Mutex::new(None)) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RetryableFailureKind { - BackendClientInit, - Request { status_code: Option }, -} - -impl RetryableFailureKind { - fn status_code(self) -> Option { - match self { - Self::BackendClientInit => None, - Self::Request { status_code } => status_code, - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum FetchAttemptError { - Retryable(RetryableFailureKind), - Unauthorized { - status_code: Option, - message: String, - }, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -enum CacheLoadStatus { - #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] - AuthIdentityIncomplete, - #[error("Cloud requirements cache file not found.")] - CacheFileNotFound, - #[error("Failed to read cloud requirements cache: {0}.")] - CacheReadFailed(String), - #[error("Failed to parse cloud requirements cache: {0}.")] - CacheParseFailed(String), - #[error("Cloud requirements cache failed signature verification.")] - CacheSignatureInvalid, - #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] - CacheIdentityIncomplete, - #[error("Ignoring cloud requirements cache for different auth identity.")] - CacheIdentityMismatch, - #[error("Cloud requirements cache expired.")] - CacheExpired, -} - -#[derive(Debug, Error)] -enum CloudRequirementsError { - #[error("failed to write cloud requirements cache")] - CacheWrite, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload, - signature: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct CloudRequirementsCacheSignedPayload { - cached_at: DateTime, - expires_at: DateTime, - chatgpt_user_id: Option, - account_id: Option, - contents: Option, -} - -impl CloudRequirementsCacheSignedPayload { - fn requirements(&self, requirements_base_dir: &Path) -> Option { - self.contents.as_deref().and_then(|contents| { - parse_cloud_requirements(contents, requirements_base_dir) - .ok() - .flatten() - }) - } -} -fn sign_cache_payload(payload_bytes: &[u8]) -> Option { - let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; - mac.update(payload_bytes); - let signature = mac.finalize().into_bytes(); - Some(BASE64_STANDARD.encode(signature)) -} - -fn verify_cache_signature_with_key( - payload_bytes: &[u8], - signature_bytes: &[u8], - key: &[u8], -) -> bool { - let mut mac = match HmacSha256::new_from_slice(key) { - Ok(mac) => mac, - Err(_) => return false, - }; - mac.update(payload_bytes); - mac.verify_slice(signature_bytes).is_ok() -} - -fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { - let signature_bytes = match BASE64_STANDARD.decode(signature) { - Ok(signature_bytes) => signature_bytes, - Err(_) => return false, - }; - - CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS - .iter() - .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) -} - -fn auth_identity(auth: &CodexAuth) -> (Option, Option) { - (auth.get_chatgpt_user_id(), auth.get_account_id()) -} - -fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool { - let Some(plan_type) = auth.account_plan_type() else { - return false; - }; - auth.uses_codex_backend() - && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) -} - -fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { - serde_json::to_vec(&payload).ok() -} - -#[async_trait] -trait RequirementsFetcher: Send + Sync { - /// Returns `Ok(None)` when there are no cloud requirements for the account. - /// - /// Returning `Err` indicates cloud requirements could not be fetched. - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result, FetchAttemptError>; -} - -struct BackendRequirementsFetcher { - base_url: String, -} - -impl BackendRequirementsFetcher { - fn new(base_url: String) -> Self { - Self { base_url } - } -} - -#[async_trait] -impl RequirementsFetcher for BackendRequirementsFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - let client = BackendClient::from_auth(self.base_url.clone(), auth) - .inspect_err(|err| { - tracing::warn!( - error = %err, - "Failed to construct backend client for cloud requirements" - ); - }) - .map_err(|_| FetchAttemptError::Retryable(RetryableFailureKind::BackendClientInit))?; - - let response = client - .get_config_requirements_file() - .await - .inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements")) - .map_err(|err| { - let status_code = err.status().map(|status| status.as_u16()); - if err.is_unauthorized() { - FetchAttemptError::Unauthorized { - status_code, - message: err.to_string(), - } - } else { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) - } - })?; - - let Some(contents) = response.contents else { - tracing::info!( - "Cloud requirements response missing contents; treating as no requirements" - ); - return Ok(None); - }; - - Ok(Some(contents)) - } -} - -#[derive(Clone)] -struct CloudRequirementsService { - auth_manager: Arc, - fetcher: Arc, - requirements_base_dir: PathBuf, - cache_path: PathBuf, - timeout: Duration, -} - -impl CloudRequirementsService { - fn new( - auth_manager: Arc, - fetcher: Arc, - codex_home: PathBuf, - timeout: Duration, - ) -> Self { - Self { - auth_manager, - fetcher, - requirements_base_dir: codex_home.clone(), - cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), - timeout, - } - } - - async fn fetch_with_timeout( - &self, - ) -> Result, CloudRequirementsLoadError> { - let _timer = - codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]); - let started_at = Instant::now(); - let fetch_result = timeout(self.timeout, self.fetch()) - .await - .inspect_err(|_| { - let message = format!( - "Timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - ); - tracing::error!("{message}"); - emit_load_metric("startup", "error"); - }) - .map_err(|_| { - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Timeout, - /*status_code*/ None, - format!( - "timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - ), - ) - })?; - - let result = match fetch_result { - Ok(result) => result, - Err(err) => { - emit_load_metric("startup", "error"); - return Err(err); - } - }; - - match result.as_ref() { - Some(requirements) => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - requirements = ?requirements, - "Cloud requirements load completed" - ); - emit_load_metric("startup", "success"); - } - None => { - tracing::info!( - elapsed_ms = started_at.elapsed().as_millis(), - "Cloud requirements load completed (none)" - ); - emit_load_metric("startup", "success"); - } - } - - Ok(result) - } - - async fn fetch(&self) -> Result, CloudRequirementsLoadError> { - let Some(auth) = self.auth_manager.auth().await else { - return Ok(None); - }; - if !cloud_requirements_eligible_auth(&auth) { - return Ok(None); - } - let (chatgpt_user_id, account_id) = auth_identity(&auth); - - match self - .load_cache(chatgpt_user_id.as_deref(), account_id.as_deref()) - .await - { - Ok(signed_payload) => { - tracing::info!( - path = %self.cache_path.display(), - "Using cached cloud requirements" - ); - return Ok(signed_payload.requirements(&self.requirements_base_dir)); - } - Err(cache_load_status) => { - self.log_cache_load_status(&cache_load_status); - } - } - - self.fetch_with_retries(auth, "startup").await - } - - async fn fetch_with_retries( - &self, - mut auth: CodexAuth, - trigger: &'static str, - ) -> Result, CloudRequirementsLoadError> { - let mut attempt = 1; - let mut last_status_code: Option = None; - let mut auth_recovery = self.auth_manager.unauthorized_recovery(); - - while attempt <= CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let contents = match self.fetcher.fetch_requirements(&auth).await { - Ok(contents) => { - emit_fetch_attempt_metric( - trigger, attempt, "success", /*status_code*/ None, - ); - contents - } - Err(FetchAttemptError::Retryable(status)) => { - let status_code = status.status_code(); - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "error", status_code); - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - status = ?status, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to fetch cloud requirements; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - Err(FetchAttemptError::Unauthorized { - status_code, - message, - }) => { - last_status_code = status_code; - emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); - if auth_recovery.has_next() { - tracing::warn!( - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Cloud requirements request was unauthorized; attempting auth recovery" - ); - match auth_recovery.next().await { - Ok(_) => { - let Some(refreshed_auth) = self.auth_manager.auth().await else { - tracing::error!( - "Auth recovery succeeded but no auth is available for cloud requirements" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_missing_auth", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - }; - auth = refreshed_auth; - continue; - } - Err(RefreshTokenError::Permanent(failed)) => { - tracing::warn!( - error = %failed, - "Failed to recover from unauthorized cloud requirements request" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unrecoverable", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, - status_code, - failed.message, - )); - } - Err(RefreshTokenError::Transient(recovery_err)) => { - if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - tracing::warn!( - error = %recovery_err, - attempt, - max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - "Failed to recover from unauthorized cloud requirements request; retrying" - ); - sleep(backoff(attempt as u64)).await; - } - attempt += 1; - continue; - } - } - } - - tracing::warn!( - error = %message, - "Cloud requirements request was unauthorized and no auth recovery is available" - ); - emit_fetch_final_metric( - trigger, - "error", - "auth_recovery_unavailable", - attempt, - status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, - status_code, - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, - )); - } - }; - - let requirements = match contents.as_deref() { - Some(contents) => { - match parse_cloud_requirements(contents, &self.requirements_base_dir) { - Ok(requirements) => requirements, - Err(err) => { - tracing::error!(error = %err, "Failed to parse cloud requirements"); - emit_fetch_final_metric( - trigger, - "error", - "parse_error", - attempt, - last_status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Parse, - /*status_code*/ None, - format_cloud_requirements_parse_failed_message(contents, &err), - )); - } - } - } - None => None, - }; - - let (chatgpt_user_id, account_id) = auth_identity(&auth); - if let Err(err) = self.save_cache(chatgpt_user_id, account_id, contents).await { - tracing::warn!(error = %err, "Failed to write cloud requirements cache"); - } - - emit_fetch_final_metric( - trigger, "success", "none", attempt, /*status_code*/ None, - ); - return Ok(requirements); - } - - emit_fetch_final_metric( - trigger, - "error", - "request_retry_exhausted", - CLOUD_REQUIREMENTS_MAX_ATTEMPTS, - last_status_code, - ); - tracing::error!( - path = %self.cache_path.display(), - "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" - ); - Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, - last_status_code, - CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, - )) - } - - async fn refresh_cache_in_background(&self) { - loop { - sleep(CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL).await; - match timeout(self.timeout, self.refresh_cache()).await { - Ok(true) => {} - Ok(false) => break, - Err(_) => { - tracing::error!( - "Timed out refreshing cloud requirements cache from remote; keeping existing cache" - ); - emit_load_metric("refresh", "error"); - } - } - } - } - - async fn refresh_cache(&self) -> bool { - let Some(auth) = self.auth_manager.auth().await else { - return false; - }; - if !cloud_requirements_eligible_auth(&auth) { - return false; - } - - match self.fetch_with_retries(auth, "refresh").await { - Ok(_) => emit_load_metric("refresh", "success"), - Err(err) => { - tracing::error!( - path = %self.cache_path.display(), - error = %err, - "Failed to refresh cloud requirements cache from remote" - ); - emit_load_metric("refresh", "error"); - } - } - true - } - - async fn load_cache( - &self, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Result { - let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { - return Err(CacheLoadStatus::AuthIdentityIncomplete); - }; - - let bytes = match fs::read(&self.cache_path).await { - Ok(bytes) => bytes, - Err(err) => { - if err.kind() != std::io::ErrorKind::NotFound { - return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); - } - return Err(CacheLoadStatus::CacheFileNotFound); - } - }; - - let cache_file: CloudRequirementsCacheFile = match serde_json::from_slice(&bytes) { - Ok(cache_file) => cache_file, - Err(err) => { - return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); - } - }; - let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { - Some(payload_bytes) => payload_bytes, - None => { - return Err(CacheLoadStatus::CacheParseFailed( - "failed to serialize cache payload".to_string(), - )); - } - }; - if !verify_cache_signature(&payload_bytes, &cache_file.signature) { - return Err(CacheLoadStatus::CacheSignatureInvalid); - } - - let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( - cache_file.signed_payload.chatgpt_user_id.as_deref(), - cache_file.signed_payload.account_id.as_deref(), - ) else { - return Err(CacheLoadStatus::CacheIdentityIncomplete); - }; - - if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { - return Err(CacheLoadStatus::CacheIdentityMismatch); - } - - if cache_file.signed_payload.expires_at <= Utc::now() { - return Err(CacheLoadStatus::CacheExpired); - } - - Ok(cache_file.signed_payload) - } - - fn log_cache_load_status(&self, status: &CacheLoadStatus) { - if matches!(status, CacheLoadStatus::CacheFileNotFound) { - return; - } - - let warn = matches!( - status, - CacheLoadStatus::CacheReadFailed(_) - | CacheLoadStatus::CacheParseFailed(_) - | CacheLoadStatus::CacheSignatureInvalid - ); - - if warn { - tracing::warn!(path = %self.cache_path.display(), "{status}"); - } else { - tracing::info!(path = %self.cache_path.display(), "{status}"); - } - } - - async fn save_cache( - &self, - chatgpt_user_id: Option, - account_id: Option, - contents: Option, - ) -> Result<(), CloudRequirementsError> { - let now = Utc::now(); - let expires_at = now - .checked_add_signed( - ChronoDuration::from_std(CLOUD_REQUIREMENTS_CACHE_TTL) - .map_err(|_| CloudRequirementsError::CacheWrite)?, - ) - .ok_or(CloudRequirementsError::CacheWrite)?; - let signed_payload = CloudRequirementsCacheSignedPayload { - cached_at: now, - expires_at, - chatgpt_user_id, - account_id, - contents, - }; - let payload_bytes = - cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; - let serialized = serde_json::to_vec_pretty(&CloudRequirementsCacheFile { - signature: sign_cache_payload(&payload_bytes) - .ok_or(CloudRequirementsError::CacheWrite)?, - signed_payload, - }) - .map_err(|_| CloudRequirementsError::CacheWrite)?; - - if let Some(parent) = self.cache_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - } - - fs::write(&self.cache_path, serialized) - .await - .map_err(|_| CloudRequirementsError::CacheWrite)?; - Ok(()) - } -} - -pub fn cloud_requirements_loader( - auth_manager: Arc, - chatgpt_base_url: String, - codex_home: PathBuf, -) -> CloudRequirementsLoader { - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), - codex_home, - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let refresh_service = service.clone(); - let task = tokio::spawn(async move { service.fetch_with_timeout().await }); - let refresh_task = - tokio::spawn(async move { refresh_service.refresh_cache_in_background().await }); - let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| { - tracing::warn!("cloud requirements refresher task slot was poisoned"); - err.into_inner() - }); - if let Some(existing_task) = refresher_guard.replace(refresh_task) { - existing_task.abort(); - } - CloudRequirementsLoader::new(async move { - task.await.map_err(|err| { - tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Internal, - /*status_code*/ None, - format!("cloud requirements load failed: {err}"), - ) - })? - }) -} - -pub async fn cloud_requirements_loader_for_storage( - codex_home: PathBuf, - enable_codex_api_key_env: bool, - credentials_store_mode: AuthCredentialsStoreMode, - chatgpt_base_url: String, -) -> CloudRequirementsLoader { - let auth_manager = AuthManager::shared( - codex_home.clone(), - enable_codex_api_key_env, - credentials_store_mode, - Some(chatgpt_base_url.clone()), - ) - .await; - cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) -} - -fn parse_cloud_requirements( - contents: &str, - requirements_base_dir: &Path, -) -> Result, toml::de::Error> { - if contents.trim().is_empty() { - return Ok(None); - } - - let _guard = AbsolutePathBufGuard::new(requirements_base_dir); - let requirements: ConfigRequirementsToml = toml::from_str(contents)?; - if requirements.is_empty() { - Ok(None) - } else { - Ok(Some(requirements)) - } -} - -fn format_cloud_requirements_parse_failed_message( - _contents: &str, - err: &toml::de::Error, -) -> String { - format!("{CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE}\n\nDetails:\n{err}") -} - -fn emit_fetch_attempt_metric( - trigger: &str, - attempt: usize, - outcome: &str, - status_code: Option, -) { - let attempt_tag = attempt.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("attempt", attempt_tag), - ("outcome", outcome.to_string()), - ("status_code", status_code_tag), - ], - ); -} - -fn emit_fetch_final_metric( - trigger: &str, - outcome: &str, - reason: &str, - attempt_count: usize, - status_code: Option, -) { - let attempt_count_tag = attempt_count.to_string(); - let status_code_tag = status_code_tag(status_code); - emit_metric( - CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ("reason", reason.to_string()), - ("attempt_count", attempt_count_tag), - ("status_code", status_code_tag), - ], - ); -} - -fn emit_load_metric(trigger: &str, outcome: &str) { - emit_metric( - CLOUD_REQUIREMENTS_LOAD_METRIC, - vec![ - ("trigger", trigger.to_string()), - ("outcome", outcome.to_string()), - ], - ); -} - -fn status_code_tag(status_code: Option) -> String { - status_code - .map(|status_code| status_code.to_string()) - .unwrap_or_else(|| "none".to_string()) -} - -fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { - if let Some(metrics) = codex_otel::global() { - let tag_refs = tags - .iter() - .map(|(key, value)| (*key, value.as_str())) - .collect::>(); - let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_config::AppToolApproval; - use codex_config::types::AuthCredentialsStoreMode; - use codex_login::auth::AgentIdentityAuth; - use codex_login::auth::AgentIdentityAuthRecord; - use codex_protocol::protocol::AskForApproval; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::collections::BTreeMap; - use std::collections::VecDeque; - use std::ffi::OsString; - use std::future::pending; - use std::io::Read; - use std::io::Write; - use std::net::TcpListener; - use std::path::Path; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use std::thread; - use tempfile::TempDir; - use tempfile::tempdir; - - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - - fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { - std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; - Ok(()) - } - - async fn auth_manager_with_api_key() -> Arc { - let tmp = tempdir().expect("tempdir"); - let auth_json = json!({ - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null, - }); - write_auth_json(tmp.path(), auth_json).expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - async fn auth_manager_with_plan_and_identity( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - ) -> Arc { - let tmp = tempdir().expect("tempdir"); - write_auth_json( - tmp.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - "test-access-token", - "test-refresh-token", - ), - ) - .expect("write auth"); - Arc::new( - AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ) - } - - fn chatgpt_auth_json( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_last_refresh( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - "2025-01-01T00:00:00Z", - ) - } - - fn chatgpt_auth_json_with_last_refresh( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - ) -> serde_json::Value { - chatgpt_auth_json_with_mode( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - last_refresh, - /*auth_mode*/ None, - ) - } - - fn chatgpt_auth_json_with_mode( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - last_refresh: &str, - auth_mode: Option<&str>, - ) -> serde_json::Value { - let header = json!({ "alg": "none", "typ": "JWT" }); - let auth_payload = json!({ - "chatgpt_plan_type": plan_type, - "chatgpt_user_id": chatgpt_user_id, - "user_id": chatgpt_user_id, - }); - let payload = json!({ - "email": "user@example.com", - "https://api.openai.com/auth": auth_payload, - }); - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); - let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let mut auth_json = json!({ - "OPENAI_API_KEY": null, - "tokens": { - "id_token": fake_jwt, - "access_token": access_token, - "refresh_token": refresh_token, - "account_id": account_id, - }, - "last_refresh": last_refresh, - }); - if let Some(auth_mode) = auth_mode { - auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); - } - auth_json - } - - struct ManagedAuthContext { - _home: TempDir, - manager: Arc, - } - - async fn managed_auth_context( - plan_type: &str, - chatgpt_user_id: Option<&str>, - account_id: Option<&str>, - access_token: &str, - refresh_token: &str, - ) -> ManagedAuthContext { - let home = tempdir().expect("tempdir"); - write_auth_json( - home.path(), - chatgpt_auth_json( - plan_type, - chatgpt_user_id, - account_id, - access_token, - refresh_token, - ), - ) - .expect("write auth"); - ManagedAuthContext { - manager: Arc::new( - AuthManager::new( - home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ), - _home: home, - } - } - - async fn auth_manager_with_plan(plan_type: &str) -> Arc { - auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) - .await - } - - fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| { - parse_cloud_requirements(contents, &std::env::temp_dir()) - .ok() - .flatten() - }) - } - - fn request_error() -> FetchAttemptError { - FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code: None }) - } - - struct StaticFetcher { - contents: Option, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for StaticFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - Ok(self.contents.clone()) - } - } - - struct PendingFetcher; - - #[async_trait::async_trait] - impl RequirementsFetcher for PendingFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - pending::<()>().await; - Ok(None) - } - } - - struct SequenceFetcher { - responses: tokio::sync::Mutex, FetchAttemptError>>>, - request_count: AtomicUsize, - } - - impl SequenceFetcher { - fn new(responses: Vec, FetchAttemptError>>) -> Self { - Self { - responses: tokio::sync::Mutex::new(VecDeque::from(responses)), - request_count: AtomicUsize::new(0), - } - } - } - - #[async_trait::async_trait] - impl RequirementsFetcher for SequenceFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - self.request_count.fetch_add(1, Ordering::SeqCst); - let mut responses = self.responses.lock().await; - responses.pop_front().unwrap_or(Ok(None)) - } - } - - struct TokenFetcher { - expected_token: String, - contents: String, - request_count: AtomicUsize, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for TokenFetcher { - async fn fetch_requirements( - &self, - auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - self.request_count.fetch_add(1, Ordering::SeqCst); - if matches!( - auth.get_token().as_deref(), - Ok(token) if token == self.expected_token.as_str() - ) { - Ok(Some(self.contents.clone())) - } else { - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: "GET /config/requirements failed: 401".to_string(), - }) - } - } - } - - struct UnauthorizedFetcher { - message: String, - request_count: AtomicUsize, - } - - #[async_trait::async_trait] - impl RequirementsFetcher for UnauthorizedFetcher { - async fn fetch_requirements( - &self, - _auth: &CodexAuth, - ) -> Result, FetchAttemptError> { - self.request_count.fetch_add(1, Ordering::SeqCst); - Err(FetchAttemptError::Unauthorized { - status_code: Some(401), - message: self.message.clone(), - }) - } - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key().await; - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(StaticFetcher { contents: None }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("pro").await, - Arc::new(StaticFetcher { contents: None }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let result = service.fetch().await; - assert_eq!(result, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("self_serve_business_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - assert_eq!(service.fetch().await, Ok(None)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_plan() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - } - - #[tokio::test] - async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server"); - let addr = listener - .local_addr() - .expect("task registration server addr"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept task registration request"); - let mut request = [0; 4096]; - let _ = stream - .read(&mut request) - .expect("read task registration request"); - let body = r#"{"task_id":"task-123"}"#; - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ) - .expect("write task registration response"); - }); - let record = AgentIdentityAuthRecord { - agent_runtime_id: "agent-runtime-123".to_string(), - agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D" - .to_string(), - account_id: "account-12345".to_string(), - chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), - plan_type: PlanType::Business, - chatgpt_account_is_fedramp: false, - }; - let authapi_base_url = format!("http://{addr}/backend-api"); - let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL"); - unsafe { - std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url); - } - let _authapi_guard = EnvVarGuard { - key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", - original: original_authapi_base_url, - }; - let auth = AgentIdentityAuth::load(record) - .await - .map(CodexAuth::AgentIdentity) - .expect("agent identity auth"); - server.join().expect("task registration server joined"); - - assert!(cloud_requirements_eligible_auth(&auth)); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise_cbp_usage_based").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("hc").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_missing_contents() { - let result = parse_for_fetch(/*contents*/ None); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_empty_contents() { - let result = parse_for_fetch(Some(" ")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_handles_invalid_toml() { - let result = parse_for_fetch(Some("not = [")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_empty_requirements() { - let result = parse_for_fetch(Some("# comment")); - assert!(result.is_none()); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_valid_toml() { - let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]")); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - Arc::new(StaticFetcher { - contents: Some( - r#" -[permissions.filesystem] -deny_read = ["./sensitive/**/*.txt"] -"# - .to_string(), - ), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let deny_read = format!("{}/sensitive/**/*.txt", codex_home.path().display()); - let expected = toml::from_str::(&format!( - r#" -[permissions.filesystem] -deny_read = [{deny_read:?}] -"# - )) - .expect("parse expected cloud requirements"); - - assert_eq!(service.fetch().await, Ok(Some(expected))); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa] -enabled = false - -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.allowed_approvals_reviewers] -user = true -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - enabled: Some(false), - allowed_approvals_reviewers: Some(BTreeMap::from([( - codex_protocol::config_types::ApprovalsReviewer::User, - true, - )])), - ..Default::default() - }, - )]), - }), - ..Default::default() - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"] -approval_mode = "approve" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - apps: Some(codex_config::AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_config::AppRequirementToml { - tools: Some(codex_config::AppToolsRequirementsToml { - tools: BTreeMap::from([( - "calendar/list_events".to_string(), - codex_config::AppToolRequirementToml { - approval_mode: Some(AppToolApproval::Approve), - }, - )]), - }), - ..Default::default() - }, - )]), - }), - ..Default::default() - }) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() { - let result = parse_for_fetch(Some( - r#" -[plugins."sample@test".mcp_servers.sample.identity] -command = "sample-mcp" -"#, - )); - - assert_eq!( - result, - Some(ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - codex_config::McpServerRequirement { - identity: codex_config::McpServerIdentity::Command { - command: "sample-mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }) - ); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_times_out() { - let auth_manager = auth_manager_with_plan("enterprise").await; - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - Arc::new(PendingFetcher), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); - tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await; - - let result = handle.await.expect("cloud requirements task"); - let err = result.expect_err("cloud requirements timeout should fail closed"); - assert!( - err.to_string() - .contains("timed out waiting for cloud requirements") - ); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_retries_until_success() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(1)).await; - - assert_eq!( - handle.await.expect("cloud requirements task"), - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - // Keep auth "fresh" so the first request hits unauthorized recovery - // instead of AuthManager::auth() proactively reloading from disk. - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write initial auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_last_refresh( - "business", - Some("user-99999"), - Some("account-12345"), - "fresh-access-token", - "test-refresh-token", - "3025-01-01T00:00:00Z", - ), - ) - .expect("write refreshed auth"); - let auth = ManagedAuthContext { - _home: auth_home, - manager: auth_manager, - }; - - let fetcher = Arc::new(TokenFetcher { - expected_token: "fresh-access-token".to_string(), - contents: "allowed_approval_policies = [\"never\"]".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-99999".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn fetch_cloud_requirements_surfaces_auth_recovery_message() { - let auth = managed_auth_context( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "stale-access-token", - "test-refresh-token", - ) - .await; - write_auth_json( - auth._home.path(), - chatgpt_auth_json( - "enterprise", - Some("user-12345"), - Some("account-99999"), - "fresh-access-token", - "test-refresh-token", - ), - ) - .expect("write mismatched auth"); - - let fetcher = Arc::new(UnauthorizedFetcher { - message: "GET /config/requirements failed: 401".to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - Arc::clone(&auth.manager), - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud requirements should surface auth recovery errors"); - assert_eq!( - err.to_string(), - "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again." - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_unauthorized_without_recovery_uses_generic_message() { - let auth_home = tempdir().expect("tempdir"); - write_auth_json( - auth_home.path(), - chatgpt_auth_json_with_mode( - "enterprise", - Some("user-12345"), - Some("account-12345"), - "test-access-token", - "test-refresh-token", - "2025-01-01T00:00:00Z", - Some("chatgptAuthTokens"), - ), - ) - .expect("write auth"); - let auth_manager = Arc::new( - AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await, - ); - - let fetcher = Arc::new(UnauthorizedFetcher { - message: - "GET https://chatgpt.com/backend-api/wham/config/requirements failed: 401; content-type=text/html; body=nope" - .to_string(), - request_count: AtomicUsize::new(0), - }); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("cloud requirements should fail closed"); - assert_eq!( - err.to_string(), - CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE - ); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); - assert_eq!(err.status_code(), Some(401)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_parse_error_does_not_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("not = [".to_string())), - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("parse error should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("Details:")); - assert!(err_text.contains("not = [")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_invalid_enum_value_surfaces_field_name() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"definitely-not-valid\"]".to_string(), - ))])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let err = service - .fetch() - .await - .expect_err("invalid enum value should fail closed"); - let err_text = err.to_string(); - assert!(err_text.contains(CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE)); - assert!(err_text.contains("allowed_approval_policies")); - assert!(err_text.contains("definitely-not-valid")); - assert!(err_text.contains("unknown variant")); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Parse); - } - - #[tokio::test] - async fn fetch_cloud_requirements_uses_cache_when_valid() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - } - - #[tokio::test] - async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - /*chatgpt_user_id*/ None, - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-12345"), - Some("account-12345"), - ) - .await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity( - "business", - Some("user-99999"), - Some("account-12345"), - ) - .await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_tampered_cache() { - let codex_home = tempdir().expect("tempdir"); - let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - let _ = prime_service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let mut cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) - .expect("parse cache"); - cache_file.signed_payload.contents = - Some("allowed_approval_policies = [\"on-request\"]".to_string()); - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_ignores_expired_cache() { - let codex_home = tempdir().expect("tempdir"); - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file = CloudRequirementsCacheFile { - signed_payload: CloudRequirementsCacheSignedPayload { - cached_at: Utc::now(), - expires_at: Utc::now() - ChronoDuration::seconds(1), - chatgpt_user_id: Some("user-12345".to_string()), - account_id: Some("account-12345".to_string()), - contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), - }, - signature: String::new(), - }; - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); - let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); - let cache_file = CloudRequirementsCacheFile { - signature, - ..cache_file - }; - std::fs::write( - &path, - serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), - ) - .expect("write cache"); - - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( - "allowed_approval_policies = [\"never\"]".to_string(), - ))])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn fetch_cloud_requirements_writes_signed_cache() { - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - Arc::new(StaticFetcher { - contents: Some("allowed_approval_policies = [\"never\"]".to_string()), - }), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let _ = service.fetch().await; - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert!( - cache_file.signed_payload.expires_at - <= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30) - ); - assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at); - assert!(cache_file.signed_payload.cached_at <= Utc::now()); - assert_eq!( - cache_file.signed_payload.chatgpt_user_id, - Some("user-12345".to_string()) - ); - assert_eq!( - cache_file.signed_payload.account_id, - Some("account-12345".to_string()) - ); - assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); - assert!(verify_cache_signature( - &payload_bytes, - &cache_file.signature - )); - } - - #[tokio::test] - async fn fetch_cloud_requirements_none_is_success_without_retry() { - let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!(service.fetch().await, Ok(None)); - assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn fetch_cloud_requirements_stops_after_max_retries() { - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Err(request_error()); - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ])); - let codex_home = tempdir().expect("tempdir"); - let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise").await, - fetcher.clone(), - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - let handle = tokio::spawn(async move { service.fetch().await }); - tokio::task::yield_now().await; - tokio::time::advance(Duration::from_secs(5)).await; - tokio::task::yield_now().await; - - let err = handle - .await - .expect("cloud requirements task") - .expect_err("cloud requirements retry exhaustion should fail closed"); - assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); - assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); - assert_eq!( - fetcher.request_count.load(Ordering::SeqCst), - CLOUD_REQUIREMENTS_MAX_ATTEMPTS - ); - } - - #[tokio::test] - async fn refresh_from_remote_updates_cached_cloud_requirements() { - let codex_home = tempdir().expect("tempdir"); - let fetcher = Arc::new(SequenceFetcher::new(vec![ - Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), - Ok(Some( - "allowed_approval_policies = [\"on-request\"]".to_string(), - )), - ])); - let service = CloudRequirementsService::new( - auth_manager_with_plan("business").await, - fetcher, - codex_home.path().to_path_buf(), - CLOUD_REQUIREMENTS_TIMEOUT, - ); - - assert_eq!( - service.fetch().await, - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - })) - ); - - assert!(service.refresh_cache().await); - - let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); - let cache_file: CloudRequirementsCacheFile = - serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) - .expect("parse cache"); - assert_eq!( - cache_file - .signed_payload - .contents - .as_deref() - .and_then(|contents| { - parse_cloud_requirements(contents, codex_home.path()) - .ok() - .flatten() - }), - Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }) - ); - } -} +//! This crate owns transport, caching, and refresh behavior for cloud-delivered +//! config data. Parsing and composition remain in `codex-config`. + +mod backend; +mod bundle_loader; +mod cache; +mod metrics; +mod service; +mod validation; + +pub use bundle_loader::cloud_config_bundle_loader; +pub use bundle_loader::cloud_config_bundle_loader_for_storage; diff --git a/codex-rs/cloud-config/src/metrics.rs b/codex-rs/cloud-config/src/metrics.rs new file mode 100644 index 0000000000..c1c02cce9c --- /dev/null +++ b/codex-rs/cloud-config/src/metrics.rs @@ -0,0 +1,95 @@ +use codex_config::CloudConfigBundle; + +const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt"; +const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final"; +const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load"; + +pub(crate) fn emit_fetch_attempt_metric( + trigger: &str, + attempt: usize, + outcome: &str, + status_code: Option, +) { + let attempt_tag = attempt.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("attempt", attempt_tag), + ("outcome", outcome.to_string()), + ("status_code", status_code_tag), + ], + ); +} + +pub(crate) fn emit_fetch_final_metric( + trigger: &str, + outcome: &str, + reason: &str, + attempt_count: usize, + status_code: Option, + bundle: Option<&CloudConfigBundle>, +) { + let attempt_count_tag = attempt_count.to_string(); + let status_code_tag = status_code_tag(status_code); + emit_metric( + CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("reason", reason.to_string()), + ("attempt_count", attempt_count_tag), + ("status_code", status_code_tag), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) { + emit_metric( + CLOUD_CONFIG_BUNDLE_LOAD_METRIC, + vec![ + ("trigger", trigger.to_string()), + ("outcome", outcome.to_string()), + ("bundle_shape", bundle_shape_tag(bundle)), + ], + ); +} + +pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String { + let Some(bundle) = bundle else { + return "none".to_string(); + }; + + let mut sources = Vec::new(); + if !bundle.config_toml.enterprise_managed.is_empty() { + sources.push("enterprise_config"); + } + if !bundle.requirements_toml.enterprise_managed.is_empty() { + sources.push("enterprise_requirements"); + } + + if sources.is_empty() { + "empty".to_string() + } else { + sources.sort_unstable(); + sources.join(",") + } +} + +fn status_code_tag(status_code: Option) -> String { + status_code + .map(|status_code| status_code.to_string()) + .unwrap_or_else(|| "none".to_string()) +} + +fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { + if let Some(metrics) = codex_otel::global() { + let tag_refs = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs); + } +} diff --git a/codex-rs/cloud-config/src/service.rs b/codex-rs/cloud-config/src/service.rs new file mode 100644 index 0000000000..b0e8d49e27 --- /dev/null +++ b/codex-rs/cloud-config/src/service.rs @@ -0,0 +1,500 @@ +//! Cloud config bundle lifecycle orchestration. +//! +//! Startup loads a single shared bundle from cache or backend, and a background +//! refresher keeps the cache warm for future app starts without changing the +//! already-snapshotted runtime config. + +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::cache::CacheLoadStatus; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::emit_fetch_attempt_metric; +use crate::metrics::emit_fetch_final_metric; +use crate::metrics::emit_load_metric; +use crate::validation::validate_bundle; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_login::UnauthorizedRecovery; +use codex_protocol::account::PlanType; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; + +pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15); +const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5; +const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); +const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str = + "Failed to load cloud config bundle (workspace-managed policies)."; +const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( + "Your authentication session could not be refreshed automatically. ", + "Please log out and sign in again." +); + +fn auth_identity(auth: &CodexAuth) -> (Option, Option) { + (auth.get_chatgpt_user_id(), auth.get_account_id()) +} + +fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool { + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; + auth.uses_codex_backend() + && (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) +} + +fn optional_bundle(bundle: CloudConfigBundle) -> Option { + if bundle.is_empty() { + None + } else { + Some(bundle) + } +} + +enum CachedBundleLookup { + Hit(Option), + Miss, +} + +enum UnauthorizedRecoveryAction { + RetrySameAttempt, + RetryNextAttempt, +} + +pub(crate) struct CloudConfigBundleService { + auth_manager: Arc, + client: Arc, + cache: CloudConfigBundleCache, + codex_home: AbsolutePathBuf, + timeout: Duration, +} + +impl Clone for CloudConfigBundleService { + fn clone(&self) -> Self { + Self { + auth_manager: Arc::clone(&self.auth_manager), + client: Arc::clone(&self.client), + cache: self.cache.clone(), + codex_home: self.codex_home.clone(), + timeout: self.timeout, + } + } +} + +impl CloudConfigBundleService +where + C: BundleClient + 'static, +{ + pub(crate) fn new( + auth_manager: Arc, + client: Arc, + codex_home: PathBuf, + timeout: Duration, + ) -> Self { + let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/"); + Self { + auth_manager, + client, + cache: CloudConfigBundleCache::new(codex_home.clone()), + codex_home, + timeout, + } + } + + pub(crate) async fn load_startup_bundle_with_timeout( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let _timer = + codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]); + let started_at = Instant::now(); + let load_result = timeout(self.timeout, self.load_startup_bundle()) + .await + .inspect_err(|_| { + let message = format!( + "Timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ); + tracing::error!("{message}"); + emit_load_metric("startup", "error", /*bundle*/ None); + }) + .map_err(|_| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Timeout, + /*status_code*/ None, + format!( + "timed out waiting for cloud config bundle after {}s", + self.timeout.as_secs() + ), + ) + })?; + + let result = match load_result { + Ok(result) => result, + Err(err) => { + emit_load_metric("startup", "error", /*bundle*/ None); + return Err(err); + } + }; + + match result.as_ref() { + Some(bundle) => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + config_fragments = bundle.config_toml.enterprise_managed.len(), + requirements_fragments = bundle.requirements_toml.enterprise_managed.len(), + "Cloud config bundle load completed" + ); + emit_load_metric("startup", "success", Some(bundle)); + } + None => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "Cloud config bundle load completed (none)" + ); + emit_load_metric("startup", "success", /*bundle*/ None); + } + } + + Ok(result) + } + + async fn load_startup_bundle( + &self, + ) -> Result, CloudConfigBundleLoadError> { + let Some(auth) = self.auth_manager.auth().await else { + return Ok(None); + }; + if !cloud_config_eligible_auth(&auth) { + return Ok(None); + } + + // Startup prefers a valid, identity-matched cache entry. The backend is + // only consulted on cache miss or invalid cache contents. + let (chatgpt_user_id, account_id) = auth_identity(&auth); + match self + .load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref()) + .await + { + CachedBundleLookup::Hit(bundle) => return Ok(bundle), + CachedBundleLookup::Miss => {} + } + + self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup") + .await + } + + async fn load_valid_cached_bundle( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> CachedBundleLookup { + match self.cache.load(chatgpt_user_id, account_id).await { + Ok(signed_payload) => { + if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) { + tracing::warn!( + path = %self.cache.path().display(), + error = %err, + "Ignoring invalid cached cloud config bundle" + ); + self.cache + .log_load_status(&CacheLoadStatus::CacheInvalidBundle); + CachedBundleLookup::Miss + } else { + tracing::info!( + path = %self.cache.path().display(), + "Using cached cloud config bundle" + ); + CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle)) + } + } + Err(cache_load_status) => { + self.cache.log_load_status(&cache_load_status); + CachedBundleLookup::Miss + } + } + } + + async fn fetch_remote_bundle_and_update_cache_with_retries( + &self, + mut auth: CodexAuth, + trigger: &'static str, + ) -> Result, CloudConfigBundleLoadError> { + let mut attempt = 1; + let mut last_status_code: Option = None; + let mut auth_recovery = self.auth_manager.unauthorized_recovery(); + + while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + match self.client.get_bundle(&auth).await { + Ok(bundle) => { + return self + .validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle) + .await; + } + Err(BundleRequestError::Retryable(status)) => { + last_status_code = status.status_code(); + if self + .retry_after_request_failure(trigger, attempt, status) + .await + { + attempt += 1; + continue; + } + } + Err(BundleRequestError::Unauthorized { + status_code, + message, + }) => { + last_status_code = status_code; + match self + .handle_unauthorized( + &mut auth, + &mut auth_recovery, + trigger, + attempt, + status_code, + &message, + ) + .await? + { + UnauthorizedRecoveryAction::RetrySameAttempt => continue, + UnauthorizedRecoveryAction::RetryNextAttempt => { + attempt += 1; + continue; + } + } + } + } + + break; + } + + emit_fetch_final_metric( + trigger, + "error", + "request_retry_exhausted", + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + last_status_code, + /*bundle*/ None, + ); + tracing::error!( + path = %self.cache.path().display(), + "{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}" + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, + last_status_code, + CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE, + )) + } + + async fn validate_and_cache_remote_bundle( + &self, + auth: &CodexAuth, + trigger: &'static str, + attempt: usize, + bundle: CloudConfigBundle, + ) -> Result, CloudConfigBundleLoadError> { + emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None); + if let Err(err) = validate_bundle(&bundle, &self.codex_home) { + emit_fetch_final_metric( + trigger, + "error", + "invalid_bundle", + attempt, + /*status_code*/ None, + /*bundle*/ None, + ); + return Err(err); + } + + let (chatgpt_user_id, account_id) = auth_identity(auth); + if let Err(err) = self + .cache + .save(chatgpt_user_id, account_id, bundle.clone()) + .await + { + tracing::warn!( + error = %err, + "Failed to write cloud config bundle cache" + ); + } + + emit_fetch_final_metric( + trigger, + "success", + "none", + attempt, + /*status_code*/ None, + Some(&bundle), + ); + Ok(optional_bundle(bundle)) + } + + async fn retry_after_request_failure( + &self, + trigger: &'static str, + attempt: usize, + status: RetryableFailureKind, + ) -> bool { + let status_code = status.status_code(); + emit_fetch_attempt_metric(trigger, attempt, "error", status_code); + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + status = ?status, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to fetch cloud config bundle; retrying" + ); + sleep(backoff(attempt as u64)).await; + true + } else { + false + } + } + + async fn handle_unauthorized( + &self, + auth: &mut CodexAuth, + auth_recovery: &mut UnauthorizedRecovery, + trigger: &'static str, + attempt: usize, + status_code: Option, + message: &str, + ) -> Result { + emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); + if auth_recovery.has_next() { + tracing::warn!( + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Cloud config bundle request was unauthorized; attempting auth recovery" + ); + match auth_recovery.next().await { + Ok(_) => { + let Some(refreshed_auth) = self.auth_manager.auth().await else { + tracing::error!( + "Auth recovery succeeded but no auth is available for cloud config bundle" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_missing_auth", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )); + }; + *auth = refreshed_auth; + return Ok(UnauthorizedRecoveryAction::RetrySameAttempt); + } + Err(RefreshTokenError::Permanent(failed)) => { + tracing::warn!( + error = %failed, + "Failed to recover from unauthorized cloud config bundle request" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unrecoverable", + attempt, + status_code, + /*bundle*/ None, + ); + return Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + failed.message, + )); + } + Err(RefreshTokenError::Transient(recovery_err)) => { + if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS { + tracing::warn!( + error = %recovery_err, + attempt, + max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS, + "Failed to recover from unauthorized cloud config bundle request; retrying" + ); + sleep(backoff(attempt as u64)).await; + } + return Ok(UnauthorizedRecoveryAction::RetryNextAttempt); + } + } + } + + tracing::warn!( + error = %message, + "Cloud config bundle request was unauthorized and no auth recovery is available" + ); + emit_fetch_final_metric( + trigger, + "error", + "auth_recovery_unavailable", + attempt, + status_code, + /*bundle*/ None, + ); + Err(CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + status_code, + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + )) + } + + pub(crate) async fn refresh_cache_in_background(&self) { + loop { + sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await; + match timeout(self.timeout, self.refresh_cache_once()).await { + Ok(true) => {} + Ok(false) => break, + Err(_) => { + tracing::error!( + "Timed out refreshing cloud config bundle cache from remote; keeping existing cache" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + } + } + + async fn refresh_cache_once(&self) -> bool { + let Some(auth) = self.auth_manager.auth().await else { + return false; + }; + if !cloud_config_eligible_auth(&auth) { + return false; + } + + match self + .fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh") + .await + { + Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()), + Err(err) => { + tracing::error!( + path = %self.cache.path().display(), + error = %err, + "Failed to refresh cloud config bundle cache from remote" + ); + emit_load_metric("refresh", "error", /*bundle*/ None); + } + } + true + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs new file mode 100644 index 0000000000..a07443ba0c --- /dev/null +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -0,0 +1,1035 @@ +use super::*; +use crate::backend::BundleClient; +use crate::backend::BundleRequestError; +use crate::backend::RetryableFailureKind; +use crate::backend::bundle_from_response; +use crate::cache::CLOUD_CONFIG_BUNDLE_CACHE_FILENAME; +use crate::cache::CloudConfigBundleCache; +use crate::metrics::bundle_shape_tag; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_backend_client::ConfigBundleResponse; +use codex_backend_client::DeliveredTomlFragment; +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigFragment; +use codex_config::CloudConfigTomlBundle; +use codex_config::CloudRequirementsFragment; +use codex_config::CloudRequirementsTomlBundle; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::VecDeque; +use std::future::pending; +use std::path::Path; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> { + std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?; + Ok(()) +} + +fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache { + CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/")) +} + +async fn auth_manager_with_api_key() -> Arc { + let tmp = tempdir().expect("tempdir"); + let auth_json = json!({ + "OPENAI_API_KEY": "sk-test-key", + "tokens": null, + "last_refresh": null, + }); + write_auth_json(tmp.path(), auth_json).expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan_and_identity( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, +) -> Arc { + let tmp = tempdir().expect("tempdir"); + write_auth_json( + tmp.path(), + chatgpt_auth_json( + plan_type, + chatgpt_user_id, + account_id, + "test-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) +} + +async fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")).await +} + +fn chatgpt_auth_json( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_last_refresh( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + "2025-01-01T00:00:00Z", + ) +} + +fn chatgpt_auth_json_with_last_refresh( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, +) -> serde_json::Value { + chatgpt_auth_json_with_mode( + plan_type, + chatgpt_user_id, + account_id, + access_token, + refresh_token, + last_refresh, + /*auth_mode*/ None, + ) +} + +fn chatgpt_auth_json_with_mode( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + access_token: &str, + refresh_token: &str, + last_refresh: &str, + auth_mode: Option<&str>, +) -> serde_json::Value { + let header = json!({ "alg": "none", "typ": "JWT" }); + let auth_payload = json!({ + "chatgpt_plan_type": plan_type, + "chatgpt_user_id": chatgpt_user_id, + "user_id": chatgpt_user_id, + }); + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": auth_payload, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header")); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let mut auth_json = json!({ + "OPENAI_API_KEY": null, + "tokens": { + "id_token": fake_jwt, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + if let Some(auth_mode) = auth_mode { + auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string()); + } + auth_json +} + +fn test_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + } +} + +fn test_config_fragment() -> CloudConfigFragment { + CloudConfigFragment { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + contents: "model = \"gpt-5\"".to_string(), + } +} + +fn test_requirements_fragment() -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + } +} + +fn invalid_config_bundle() -> CloudConfigBundle { + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg_invalid".to_string(), + name: "Invalid config".to_string(), + contents: "model = [".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + } +} + +fn request_error() -> BundleRequestError { + BundleRequestError::Retryable(RetryableFailureKind::Request { status_code: None }) +} + +struct StaticBundleClient { + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl StaticBundleClient { + fn new(bundle: CloudConfigBundle) -> Self { + Self { + bundle, + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for StaticBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Ok(self.bundle.clone()) + } +} + +struct PendingBundleClient; + +impl BundleClient for PendingBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + pending::<()>().await; + Ok(CloudConfigBundle::default()) + } +} + +struct SequenceBundleClient { + responses: tokio::sync::Mutex>>, + request_count: AtomicUsize, +} + +impl SequenceBundleClient { + fn new(responses: Vec>) -> Self { + Self { + responses: tokio::sync::Mutex::new(VecDeque::from(responses)), + request_count: AtomicUsize::new(0), + } + } +} + +impl BundleClient for SequenceBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + let mut responses = self.responses.lock().await; + responses + .pop_front() + .unwrap_or_else(|| Ok(CloudConfigBundle::default())) + } +} + +struct TokenBundleClient { + expected_token: String, + bundle: CloudConfigBundle, + request_count: AtomicUsize, +} + +impl BundleClient for TokenBundleClient { + async fn get_bundle(&self, auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + if matches!( + auth.get_token().as_deref(), + Ok(token) if token == self.expected_token.as_str() + ) { + Ok(self.bundle.clone()) + } else { + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: "GET /config/bundle failed: 401".to_string(), + }) + } + } +} + +struct UnauthorizedBundleClient { + message: String, + request_count: AtomicUsize, +} + +impl BundleClient for UnauthorizedBundleClient { + async fn get_bundle(&self, _auth: &CodexAuth) -> Result { + self.request_count.fetch_add(1, Ordering::SeqCst); + Err(BundleRequestError::Unauthorized { + status_code: Some(401), + message: self.message.clone(), + }) + } +} + +#[test] +fn bundle_shape_tag_describes_sorted_enterprise_sources() { + assert_eq!(bundle_shape_tag(/*bundle*/ None), "none"); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle::default())), + "empty" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle::default(), + })), + "enterprise_config" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_requirements" + ); + assert_eq!( + bundle_shape_tag(Some(&CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![test_config_fragment()], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![test_requirements_fragment()], + }, + })), + "enterprise_config,enterprise_requirements" + ); +} + +#[tokio::test] +async fn get_bundle_skips_non_chatgpt_auth() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_api_key().await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_non_business_or_enterprise_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("pro").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_skips_team_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("self_serve_business_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_allows_business_plan_and_writes_cache() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(invalid_config_bundle())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("invalid remote bundle should fail closed"); + + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::InvalidBundle); + assert!(err.to_string().contains("invalid cloud config bundle")); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + !codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_ignores_invalid_cache_and_refetches() { + let codex_home = tempdir().expect("tempdir"); + let cache = create_test_cache(codex_home.path()); + cache + .save( + Some("user-12345".to_string()), + Some("account-12345".to_string()), + invalid_config_bundle(), + ) + .await + .expect("write invalid cache"); + let replacement_bundle = test_bundle(); + let fetcher = Arc::new(StaticBundleClient::new(replacement_bundle.clone())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle.clone())) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert_eq!( + cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load refreshed cache") + .bundle, + replacement_bundle + ); +} + +#[tokio::test] +async fn get_bundle_allows_business_like_usage_based_plan() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise_cbp_usage_based").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_allows_hc_plan_as_enterprise() { + let fetcher = Arc::new(StaticBundleClient::new(test_bundle())); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("hc").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_empty_response_is_success_and_cached() { + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(StaticBundleClient::new(CloudConfigBundle::default())); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(None)); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + assert!( + codex_home + .path() + .join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME) + .exists() + ); +} + +#[tokio::test] +async fn get_bundle_uses_cache_when_valid() { + let bundle = test_bundle(); + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(bundle.clone())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let fetcher = Arc::new(SequenceBundleClient::new(vec![Err(request_error())])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(bundle))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn get_bundle_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-12345"), Some("account-12345")) + .await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity("business", Some("user-99999"), Some("account-12345")) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_times_out() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + Arc::new(PendingBundleClient), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let handle = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await }); + tokio::time::advance(CLOUD_CONFIG_BUNDLE_TIMEOUT + Duration::from_millis(1)).await; + + let result = handle.await.expect("cloud config bundle task"); + let err = result.expect_err("cloud config bundle timeout should fail closed"); + assert!( + err.to_string() + .contains("timed out waiting for cloud config bundle") + ); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_retries_until_success() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()), + Ok(test_bundle()), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(1)).await; + + assert_eq!(handle.await.expect("bundle task"), Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + // Keep auth "fresh" so the first request hits unauthorized recovery + // instead of AuthManager::auth() proactively reloading from disk. + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_last_refresh( + "business", + Some("user-99999"), + Some("account-12345"), + "fresh-access-token", + "test-refresh-token", + "3025-01-01T00:00:00Z", + ), + ) + .expect("write refreshed auth"); + let fetcher = Arc::new(TokenBundleClient { + expected_token: "fresh-access-token".to_string(), + bundle: test_bundle(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + let cache = create_test_cache(codex_home.path()); + assert_eq!( + cache + .load(Some("user-99999"), Some("account-12345")) + .await + .expect("load cache") + .bundle, + test_bundle() + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn get_bundle_surfaces_auth_recovery_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "stale-access-token", + "test-refresh-token", + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + write_auth_json( + auth_home.path(), + chatgpt_auth_json( + "enterprise", + Some("user-12345"), + Some("account-99999"), + "fresh-access-token", + "test-refresh-token", + ), + ) + .expect("write mismatched auth"); + let fetcher = Arc::new(UnauthorizedBundleClient { + message: "GET /config/bundle failed: 401".to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should surface auth recovery errors"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { + let auth_home = tempdir().expect("tempdir"); + write_auth_json( + auth_home.path(), + chatgpt_auth_json_with_mode( + "enterprise", + Some("user-12345"), + Some("account-12345"), + "test-access-token", + "test-refresh-token", + "2025-01-01T00:00:00Z", + Some("chatgptAuthTokens"), + ), + ) + .expect("write auth"); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let fetcher = Arc::new(UnauthorizedBundleClient { + message: + "GET https://chatgpt.com/backend-api/wham/config/bundle failed: 401; content-type=text/html; body=nope" + .to_string(), + request_count: AtomicUsize::new(0), + }); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let err = service + .load_startup_bundle() + .await + .expect_err("cloud config bundle should fail closed"); + assert_eq!( + err, + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::Auth, + Some(401), + CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE, + ) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn get_bundle_does_not_use_cache_when_auth_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + Arc::new(StaticBundleClient::new(test_bundle())), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + let _ = prime_service.load_startup_bundle().await; + + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( + replacement_bundle.clone() + )])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ) + .await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!( + service.load_startup_bundle().await, + Ok(Some(replacement_bundle)) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn get_bundle_stops_after_max_retries() { + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Err(request_error()); + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("enterprise").await, + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + let handle = tokio::spawn(async move { service.load_startup_bundle().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + + let err = handle + .await + .expect("cloud config bundle task") + .expect_err("cloud config bundle retry exhaustion should fail closed"); + assert_eq!(err.to_string(), CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE); + assert_eq!(err.code(), CloudConfigBundleLoadErrorCode::RequestFailed); + assert_eq!( + fetcher.request_count.load(Ordering::SeqCst), + CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS + ); +} + +#[tokio::test] +async fn refresh_from_remote_updates_cached_bundle() { + let replacement_bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle::default(), + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Replacement requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }], + }, + }; + let codex_home = tempdir().expect("tempdir"); + let fetcher = Arc::new(SequenceBundleClient::new(vec![ + Ok(test_bundle()), + Ok(replacement_bundle.clone()), + ])); + let service = CloudConfigBundleService::new( + auth_manager_with_plan("business").await, + fetcher, + codex_home.path().to_path_buf(), + CLOUD_CONFIG_BUNDLE_TIMEOUT, + ); + + assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle()))); + assert!(service.refresh_cache_once().await); + + let cache = create_test_cache(codex_home.path()); + let signed_payload = cache + .load(Some("user-12345"), Some("account-12345")) + .await + .expect("load cache"); + assert_eq!(signed_payload.bundle, replacement_bundle); +} + +#[test] +fn bundle_response_conversion_preserves_fragment_order() { + let response = ConfigBundleResponse { + config_toml: Some(Some(Box::new(codex_backend_client::DeliveredConfigToml { + enterprise_managed: Some(Some(vec![ + DeliveredTomlFragment::new( + "cfg_high".to_string(), + "High config".to_string(), + "model = \"high\"".to_string(), + ), + DeliveredTomlFragment::new( + "cfg_low".to_string(), + "Low config".to_string(), + "model = \"low\"".to_string(), + ), + ])), + }))), + requirements_toml: Some(Some(Box::new( + codex_backend_client::DeliveredRequirementsToml { + enterprise_managed: Some(Some(vec![DeliveredTomlFragment::new( + "req_high".to_string(), + "High requirements".to_string(), + "allowed_approval_policies = [\"never\"]".to_string(), + )])), + }, + ))), + }; + + assert_eq!( + bundle_from_response(response), + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }], + }, + } + ); +} + +#[test] +fn bundle_response_conversion_treats_missing_sections_as_empty() { + assert_eq!( + bundle_from_response(ConfigBundleResponse::new()), + CloudConfigBundle::default() + ); +} diff --git a/codex-rs/cloud-config/src/validation.rs b/codex-rs/cloud-config/src/validation.rs new file mode 100644 index 0000000000..ef5ed06e1a --- /dev/null +++ b/codex-rs/cloud-config/src/validation.rs @@ -0,0 +1,34 @@ +use codex_config::AbsolutePathBuf; +use codex_config::CloudConfigBundle; +use codex_config::CloudConfigBundleLayers; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoadErrorCode; +use codex_config::compose_requirements; + +pub(crate) fn validate_bundle( + bundle: &CloudConfigBundle, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigBundleLoadError> { + let bundle_layers = + CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + let CloudConfigBundleLayers { + enterprise_managed_config: _, + enterprise_managed_requirements, + } = bundle_layers; + + compose_requirements(enterprise_managed_requirements).map_err(|err| { + CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::InvalidBundle, + /*status_code*/ None, + format!("invalid cloud config bundle: {err}"), + ) + })?; + + Ok(()) +} diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs new file mode 100644 index 0000000000..cfece32a01 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -0,0 +1,214 @@ +//! Cloud config bundle domain model and shared in-memory loader. +//! +//! The backend bundle groups cloud-delivered config and requirements fragments +//! by source bucket. `CloudConfigBundleLayers` converts those raw buckets into +//! layer entries while preserving each bucket's insertion semantics. + +use crate::CloudConfigFragment; +use crate::ConfigLayerEntry; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::cloud_config_layers::CloudConfigLayerError; +use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict; +use crate::cloud_config_layers_from_fragments; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::future::Future; +use thiserror::Error; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigBundle { + pub config_toml: CloudConfigTomlBundle, + pub requirements_toml: CloudRequirementsTomlBundle, +} + +impl CloudConfigBundle { + pub fn is_empty(&self) -> bool { + let CloudConfigBundle { + config_toml, + requirements_toml, + } = self; + let CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + } = config_toml; + let CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + } = requirements_toml; + + config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsTomlBundle { + pub enterprise_managed: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +/// Cloud config bundle converted into semantic layer buckets. +/// +/// This is not a final config stack. Callers still decide where each bucket is +/// inserted relative to local/system/user layers. +#[derive(Clone, Debug)] +pub struct CloudConfigBundleLayers { + /// Enterprise-managed config layers in `ConfigLayerStack` order. + pub enterprise_managed_config: Vec, + /// Enterprise-managed requirements layers in requirements layer merge order. + pub enterprise_managed_requirements: Vec, +} + +impl CloudConfigBundleLayers { + pub fn from_bundle( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false) + } + + pub fn from_bundle_strict_config( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + ) -> Result { + Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true) + } + + fn from_bundle_impl( + bundle: CloudConfigBundle, + base_dir: &AbsolutePathBuf, + strict_config: bool, + ) -> Result { + // Keep this destructuring exhaustive so adding a new bundle bucket forces + // an explicit choice about how it becomes layer data. + let CloudConfigBundle { + config_toml: + CloudConfigTomlBundle { + enterprise_managed: config_enterprise_managed, + }, + requirements_toml: + CloudRequirementsTomlBundle { + enterprise_managed: requirements_enterprise_managed, + }, + } = bundle; + + let enterprise_managed_config = if strict_config { + cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)? + } else { + cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)? + }; + + let mut enterprise_managed_requirements = requirements_enterprise_managed + .into_iter() + .map(|fragment| { + RequirementsLayerEntry::from_toml( + RequirementSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + fragment.contents, + ) + .with_base_dir(base_dir.clone()) + }) + .collect::>(); + // Bundle fragments arrive highest-priority first, while requirements + // layers are merged lowest-priority to highest-priority. + enterprise_managed_requirements.reverse(); + + Ok(Self { + enterprise_managed_config, + enterprise_managed_requirements, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudConfigBundleLoadErrorCode { + Auth, + Timeout, + RequestFailed, + InvalidBundle, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct CloudConfigBundleLoadError { + code: CloudConfigBundleLoadErrorCode, + message: String, + status_code: Option, +} + +impl CloudConfigBundleLoadError { + pub fn new( + code: CloudConfigBundleLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> CloudConfigBundleLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } +} + +#[derive(Clone)] +pub struct CloudConfigBundleLoader { + fut: Shared, CloudConfigBundleLoadError>>>, +} + +impl CloudConfigBundleLoader { + pub fn new(fut: F) -> Self + where + F: Future, CloudConfigBundleLoadError>> + + Send + + 'static, + { + Self { + fut: fut.boxed().shared(), + } + } + + pub async fn get(&self) -> Result, CloudConfigBundleLoadError> { + self.fut.clone().await + } +} + +impl fmt::Debug for CloudConfigBundleLoader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CloudConfigBundleLoader").finish() + } +} + +impl Default for CloudConfigBundleLoader { + fn default() -> Self { + Self::new(async { Ok(None) }) + } +} + +#[cfg(test)] +#[path = "cloud_config_bundle_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs new file mode 100644 index 0000000000..714aca6bc6 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::ConfigLayerSource; +use crate::ConfigRequirementsToml; +use crate::compose_requirements; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +#[tokio::test] +async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudConfigBundleLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(CloudConfigBundle::default())) + }); + + let (first, second) = tokio::join!(loader.get(), loader.get()); + assert_eq!(first, second); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[test] +fn bundle_layers_preserve_enterprise_managed_bucket_order() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }, + CloudRequirementsFragment { + id: "req_low".to_string(), + name: "Low requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }, + ], + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .enterprise_managed_config + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_high".to_string(), + name: "High config".to_string(), + }, + ] + ); + assert_eq!( + compose_requirements(layers.enterprise_managed_requirements) + .expect("requirements should compose") + .expect("requirements should be present") + .into_toml(), + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + } + ); +} + +#[test] +fn bundle_layers_can_strict_validate_enterprise_managed_config() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let err = CloudConfigBundleLayers::from_bundle_strict_config( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + contents: "unknown_key = true".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: Vec::new(), + }, + }, + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: crate::CloudConfigFragmentSource { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs index bdb11631e4..f6fcd40b4a 100644 --- a/codex-rs/config/src/cloud_config_layers.rs +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -7,8 +7,13 @@ use crate::ConfigLayerEntry; use crate::ConfigLayerSource; use crate::TomlValue; +use crate::config_toml::ConfigToml; use crate::loader::resolve_relative_paths_in_config_toml; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use serde::Serialize; use std::fmt; use std::io; use thiserror::Error; @@ -18,7 +23,7 @@ use thiserror::Error; /// The bundle orders fragments from highest precedence to lowest precedence. /// This module returns config layers in stack order, so callers can append the /// result between system and user config without re-sorting. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigFragment { pub id: String, pub name: String, @@ -63,6 +68,21 @@ pub enum CloudConfigLayerError { pub fn cloud_config_layers_from_fragments( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) +} + +pub(crate) fn cloud_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) +} + +fn cloud_config_layers_from_fragments_impl( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + strict_config: bool, ) -> Result, CloudConfigLayerError> { let mut layers = Vec::new(); for fragment in fragments { @@ -73,6 +93,9 @@ pub fn cloud_config_layers_from_fragments( fragment: source_ref.clone(), message: err.to_string(), })?; + if strict_config { + validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?; + } let resolved = resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { CloudConfigLayerError::Invalid { @@ -97,6 +120,26 @@ pub fn cloud_config_layers_from_fragments( Ok(layers) } +fn validate_fragment_strictly( + source_ref: &CloudConfigFragmentSource, + raw_toml: &str, + value: &TomlValue, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigLayerError> { + let _guard = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(&source_ref.to_string(), raw_toml, value.clone()) + { + return Err(CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: config_error.message, + }); + } + + Ok(()) +} + impl From for io::Error { fn from(error: CloudConfigLayerError) -> Self { io::Error::new(io::ErrorKind::InvalidData, error) diff --git a/codex-rs/config/src/cloud_config_layers_tests.rs b/codex-rs/config/src/cloud_config_layers_tests.rs index c7f685fa67..c2bc7383bc 100644 --- a/codex-rs/config/src/cloud_config_layers_tests.rs +++ b/codex-rs/config/src/cloud_config_layers_tests.rs @@ -58,6 +58,27 @@ fn layers_are_returned_in_stack_order() { ); } +#[test] +fn strict_layers_reject_unknown_config_fields() { + let base_dir = base_dir(); + let err = cloud_config_layers_from_fragments_strict( + vec![fragment("strict", "Strict layer", "unknown_key = true")], + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: CloudConfigFragmentSource { + id: "strict".to_string(), + name: "Strict layer".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} + #[test] fn enterprise_layers_precede_user_and_override_system() { let base_dir = base_dir(); diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs deleted file mode 100644 index 85b904824d..0000000000 --- a/codex-rs/config/src/cloud_requirements.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::config_requirements::ConfigRequirementsToml; -use futures::future::BoxFuture; -use futures::future::FutureExt; -use futures::future::Shared; -use std::fmt; -use std::future::Future; -use thiserror::Error; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CloudRequirementsLoadErrorCode { - Auth, - Timeout, - Parse, - RequestFailed, - Internal, -} - -#[derive(Clone, Debug, Eq, Error, PartialEq)] -#[error("{message}")] -pub struct CloudRequirementsLoadError { - code: CloudRequirementsLoadErrorCode, - message: String, - status_code: Option, -} - -impl CloudRequirementsLoadError { - pub fn new( - code: CloudRequirementsLoadErrorCode, - status_code: Option, - message: impl Into, - ) -> Self { - Self { - code, - message: message.into(), - status_code, - } - } - - pub fn code(&self) -> CloudRequirementsLoadErrorCode { - self.code - } - - pub fn status_code(&self) -> Option { - self.status_code - } -} - -#[derive(Clone)] -pub struct CloudRequirementsLoader { - fut: Shared< - BoxFuture<'static, Result, CloudRequirementsLoadError>>, - >, -} - -impl CloudRequirementsLoader { - pub fn new(fut: F) -> Self - where - F: Future, CloudRequirementsLoadError>> - + Send - + 'static, - { - Self { - fut: fut.boxed().shared(), - } - } - - pub async fn get(&self) -> Result, CloudRequirementsLoadError> { - self.fut.clone().await - } -} - -impl fmt::Debug for CloudRequirementsLoader { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CloudRequirementsLoader").finish() - } -} - -impl Default for CloudRequirementsLoader { - fn default() -> Self { - Self::new(async { Ok(None) }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::sync::Arc; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - #[tokio::test] - async fn shared_future_runs_once() { - let counter = Arc::new(AtomicUsize::new(0)); - let counter_clone = Arc::clone(&counter); - let loader = CloudRequirementsLoader::new(async move { - counter_clone.fetch_add(1, Ordering::SeqCst); - Ok(Some(ConfigRequirementsToml::default())) - }); - - let (first, second) = tokio::join!(loader.get(), loader.get()); - assert_eq!(first, second); - assert_eq!(counter.load(Ordering::SeqCst), 1); - } -} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index c69170b6e7..47f4d7ecf9 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -29,7 +29,6 @@ pub enum RequirementSource { domain: String, key: String, }, - CloudRequirements, /// Multiple requirements layers contributed to the final value. Sources are /// stored highest-priority first, matching the order surfaced in errors. Composite { @@ -87,9 +86,6 @@ impl fmt::Display for RequirementSource { RequirementSource::MdmManagedPreferences { domain, key } => { write!(f, "MDM {domain}:{key}") } - RequirementSource::CloudRequirements => { - write!(f, "cloud requirements") - } RequirementSource::Composite { sources } => { write!(f, "requirements layers: ")?; for (index, source) in sources.iter().enumerate() { @@ -1639,22 +1635,6 @@ mod tests { ); } - #[test] - fn composite_requirement_source_display_lists_sources_in_priority_order() { - let source = RequirementSource::composite([ - RequirementSource::MdmManagedPreferences { - domain: "com.openai.codex".to_string(), - key: "requirements_toml_base64".to_string(), - }, - RequirementSource::LegacyManagedConfigTomlFromMdm, - ]); - - assert_eq!( - source.to_string(), - "requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)" - ); - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -2035,7 +2015,7 @@ mod tests { fn merge_unset_fields_ignores_blank_guardian_override() { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { guardian_policy_config: Some(" \n\t".to_string()), ..Default::default() @@ -2435,8 +2415,11 @@ allowed_approvals_reviewers = ["user"] #[test] fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { - let higher_source = RequirementSource::CloudRequirements; - let lower_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let lower_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( @@ -2477,7 +2460,7 @@ allowed_approvals_reviewers = ["user"] let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, ConfigRequirementsToml { apps: Some(apps_requirements(&[])), ..Default::default() @@ -2598,7 +2581,7 @@ allowed_approvals_reviewers = ["user"] "#, )?; - let source_location = RequirementSource::CloudRequirements; + let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields(source_location.clone(), source); let requirements = ConfigRequirements::try_from(target)?; @@ -2987,7 +2970,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_toml: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -3078,7 +3061,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { - let high_source = RequirementSource::CloudRequirements; + let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut high_precedence: ConfigRequirementsToml = from_str( r#" allowed_sandbox_modes = ["read-only"] @@ -3265,7 +3248,7 @@ statusMessage = "checking" fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields( - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, from_str::( r#" [hooks] @@ -3309,7 +3292,7 @@ command = "python3 /system/hooks/pre.py" ); assert_eq!( target.hooks.as_ref().map(|hooks| hooks.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) ); Ok(()) } @@ -3373,7 +3356,7 @@ command = "python3 /enterprise/hooks/pre.py" "/tmp/blocked.sock" = "deny" "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); @@ -3446,7 +3429,7 @@ command = "python3 /enterprise/hooks/pre.py" allow_local_binding = false "#; - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let mut requirements_with_sources = ConfigRequirementsWithSources::default(); requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index cb3984729b..0c79ffacff 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,5 +1,5 @@ +mod cloud_config_bundle; mod cloud_config_layers; -mod cloud_requirements; mod config_layer_source; mod config_requirements; pub mod config_toml; @@ -25,19 +25,25 @@ pub mod schema; mod skills_config; mod state; mod strict_config; +pub mod test_support; mod thread_config; mod tui_keymap; pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_bundle::CloudConfigBundle; +pub use cloud_config_bundle::CloudConfigBundleLayers; +pub use cloud_config_bundle::CloudConfigBundleLoadError; +pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; +pub use cloud_config_bundle::CloudConfigBundleLoader; +pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudRequirementsFragment; +pub use cloud_config_bundle::CloudRequirementsTomlBundle; pub use cloud_config_layers::CloudConfigFragment; pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; pub use cloud_config_layers::cloud_config_layers_from_fragments; -pub use cloud_requirements::CloudRequirementsLoadError; -pub use cloud_requirements::CloudRequirementsLoadErrorCode; -pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index e004cea87c..b711ebbb61 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -1,6 +1,6 @@ # `codex-config` loader -This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, cloud-managed config, managed config, and MDM-managed preferences) and to produce: - An **effective merged** TOML config. - **Per-key origins** metadata (which layer “wins” for a given key). @@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -25,13 +25,19 @@ Exported from `codex_config::loader`: Precedence is **top overrides bottom**: -1. **MDM** managed preferences (macOS only) -2. **System** managed config (e.g. `managed_config.toml`) -3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) -4. **User** config (`config.toml`) +1. `LegacyManagedConfigTomlFromMdm` (MDM-delivered `managed_config.toml`, while it is being phased out) +2. `LegacyManagedConfigTomlFromFile` (`managed_config.toml`, while it is being phased out) +3. `SessionFlags` (CLI overrides, applied as dotted-path TOML writes) +4. `Project` config (`.codex/config.toml`) +5. `User` profile config, when present +6. `User` config (`config.toml`) +7. `EnterpriseManaged` cloud-managed config bundle layers +8. `System` config (`/etc/codex/config.toml` or the Windows system config path) -Thread config entries supplied by `thread_config_loader` are inserted according -to their translated `ConfigLayerSource` precedence. +`ConfigLayerStack` stores layers in the opposite order internally: lowest +precedence first, highest precedence last, so later layers override earlier +layers when folded. Thread config entries supplied by `thread_config_loader` are +inserted according to their translated `ConfigLayerSource` precedence. Layers with a `disabled_reason` are still surfaced for UI, but are ignored when computing the effective config and origins metadata. This is what @@ -42,9 +48,8 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_config::NoopThreadConfigLoader; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; @@ -58,7 +63,6 @@ let layers = load_config_layers_state( Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ).await?; diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 199e7f6daa..974cb27604 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,6 +1,4 @@ -use super::merge_requirements_with_remote_sandbox_config; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; use crate::config_requirements::RequirementSource; use crate::config_toml::ConfigToml; use crate::diagnostics::ConfigDiagnosticSource; @@ -75,35 +73,27 @@ fn load_managed_admin_config( .transpose() } -pub(crate) async fn load_managed_admin_requirements_toml( - target: &mut ConfigRequirementsWithSources, +pub(crate) async fn load_managed_admin_requirements_layer( override_base64: Option<&str>, -) -> io::Result<()> { +) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); if trimmed.is_empty() { - return Ok(()); + return Ok(None); } - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - parse_managed_requirements_base64(trimmed)?, - ); - return Ok(()); + return parse_managed_requirements_base64(trimmed).map(|contents| { + Some(RequirementsLayerEntry::from_toml( + managed_preferences_requirements_source(), + contents, + )) + }); } match task::spawn_blocking(load_managed_admin_requirements).await { - Ok(result) => { - if let Some(requirements) = result? { - merge_requirements_with_remote_sandbox_config( - target, - managed_preferences_requirements_source(), - requirements, - ); - } - Ok(()) - } + Ok(result) => Ok(result?.map(|contents| { + RequirementsLayerEntry::from_toml(managed_preferences_requirements_source(), contents) + })), Err(join_err) => { if join_err.is_cancelled() { tracing::error!("Managed requirements load task was cancelled"); @@ -115,7 +105,7 @@ pub(crate) async fn load_managed_admin_requirements_toml( } } -fn load_managed_admin_requirements() -> io::Result> { +fn load_managed_admin_requirements() -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)? .as_deref() .map(str::trim) @@ -217,13 +207,8 @@ fn validate_managed_config_toml_strictly_if_requested( } } -fn parse_managed_requirements_base64(encoded: &str) -> io::Result { - toml::from_str::(&decode_managed_preferences_base64(encoded)?).map_err( - |err| { - tracing::error!("Failed to parse managed requirements TOML: {err}"); - io::Error::new(io::ErrorKind::InvalidData, err) - }, - ) +fn parse_managed_requirements_base64(encoded: &str) -> io::Result { + decode_managed_preferences_base64(encoded) } fn decode_managed_preferences_base64(encoded: &str) -> io::Result { diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index a657af0f36..bc0fb482e4 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -6,10 +6,10 @@ mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::CloudConfigBundleLayers; use crate::ProfileV2Name; -use crate::cloud_requirements::CloudRequirementsLoader; -use crate::config_requirements::ConfigRequirementsToml; -use crate::config_requirements::ConfigRequirementsWithSources; +use crate::RequirementsLayerEntry; +use crate::compose_requirements; use crate::config_requirements::RequirementSource; use crate::config_requirements::SandboxModeRequirement; use crate::config_toml::ConfigToml; @@ -75,14 +75,16 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } -/// To build up the set of admin-enforced constraints, we build up from multiple -/// configuration layers in the following order, but a constraint defined in an -/// earlier layer cannot be overridden by a later layer: +/// To build up the set of admin-enforced constraints, requirements layers are +/// collected in ascending precedence order, matching config layers, and then +/// composed with config-style TOML merging plus field-specific handling for +/// hooks, rules, deny-read permissions, and remote sandbox config: /// -/// - cloud: managed cloud requirements -/// - admin: managed preferences (*) /// - system `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) +/// - cloud: enterprise-managed cloud config bundle requirements +/// - legacy: managed_config.toml reinterpreted as requirements.toml +/// - admin: managed preferences (*) /// /// For backwards compatibility, we also load from /// `managed_config.toml` and map it to `requirements.toml`. @@ -92,6 +94,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) +/// - cloud enterprise-managed cloud config bundle fragments /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) @@ -114,56 +117,77 @@ pub async fn load_config_layers_state( cwd: Option, cli_overrides: &[(String, TomlValue)], options: impl Into, - cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ConfigLoadOptions { loader_overrides: overrides, strict_config, + cloud_config_bundle, } = options.into(); let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); + let mut requirements_layers = Vec::new(); + let mut bundle_requirements_layers = Vec::new(); + let mut system_requirements_layer = None; + let managed_preferences_requirements_layer; + let mut cloud_config_layers = Vec::new(); if !ignore_managed_requirements { - if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - merge_requirements_with_remote_sandbox_config( - &mut config_requirements_toml, - RequirementSource::CloudRequirements, - requirements, - ); + if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? { + let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let bundle_layers = if strict_config { + CloudConfigBundleLayers::from_bundle_strict_config(bundle, &cloud_config_base_dir)? + } else { + CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)? + }; + let CloudConfigBundleLayers { + enterprise_managed_config, + enterprise_managed_requirements, + } = bundle_layers; + bundle_requirements_layers = enterprise_managed_requirements; + cloud_config_layers = enterprise_managed_config; } #[cfg(target_os = "macos")] - macos::load_managed_admin_requirements_toml( - &mut config_requirements_toml, - overrides - .macos_managed_config_requirements_base64 - .as_deref(), - ) - .await?; + { + managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer( + overrides + .macos_managed_config_requirements_base64 + .as_deref(), + ) + .await?; + } + #[cfg(not(target_os = "macos"))] + { + managed_preferences_requirements_layer = None; + } // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; - load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; + system_requirements_layer = load_requirements_toml(fs, &requirements_toml_file).await?; + } else { + managed_preferences_requirements_layer = None; } - // Make a best-effort to support the legacy `managed_config.toml` as a - // requirements specification. let loaded_config_layers = layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { - load_requirements_from_legacy_scheme( - &mut config_requirements_toml, + requirements_layers.extend(system_requirements_layer); + requirements_layers.extend(bundle_requirements_layers); + // Continue to support the legacy `managed_config.toml` locations as + // requirements layers for backwards compatibility. + requirements_layers.extend(requirements_layers_from_legacy_scheme( loaded_config_layers.clone(), - ) - .await?; + )?); + requirements_layers.extend(managed_preferences_requirements_layer); } + let config_requirements_toml = compose_requirements(requirements_layers)?.unwrap_or_default(); + let thread_config_context = ThreadConfigContext { thread_id: None, cwd: cwd.clone(), @@ -210,6 +234,7 @@ pub async fn load_config_layers_state( ) .await?; layers.push(system_layer); + layers.extend(cloud_config_layers); // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to @@ -535,15 +560,12 @@ fn validate_cli_overrides_strictly( Ok(()) } -/// If available, apply requirements from the platform system -/// `requirements.toml` location to `config_requirements_toml` by filling in -/// any unset fields. -#[doc(hidden)] +/// If available, load requirements from the platform's system `requirements.toml` +/// location as a requirements layer. pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, - config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, -) -> io::Result<()> { +) -> io::Result> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) .await @@ -558,39 +580,31 @@ pub async fn load_requirements_toml( ), ) })?; - let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); - let requirements_config: ConfigRequirementsToml = - toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing requirements file {}: {e}", - requirements_toml_file.as_path().display(), - ), - ) - })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, - RequirementSource::SystemRequirementsToml { - file: requirements_toml_file.clone(), - }, - requirements_config, - ); + let base_dir = AbsolutePathBuf::from_absolute_path(requirements_parent)?; + Ok(Some( + RequirementsLayerEntry::from_toml( + RequirementSource::SystemRequirementsToml { + file: requirements_toml_file.clone(), + }, + contents, + ) + .with_base_dir(base_dir), + )) } Err(e) => { if e.kind() != io::ErrorKind::NotFound { - return Err(io::Error::new( + Err(io::Error::new( e.kind(), format!( "Failed to read requirements file {}: {e}", requirements_toml_file.as_path().display(), ), - )); + )) + } else { + Ok(None) } } } - - Ok(()) } #[cfg(unix)] @@ -704,31 +718,33 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { Ok(path) } -async fn load_requirements_from_legacy_scheme( - config_requirements_toml: &mut ConfigRequirementsWithSources, +fn requirements_layers_from_legacy_scheme( loaded_config_layers: LoadedConfigLayers, -) -> io::Result<()> { - // In this implementation, earlier layers cannot be overwritten by later - // layers, so list managed_config_from_mdm first because it has the highest - // precedence. +) -> io::Result> { + // List the file-backed legacy layer first because requirements layers are + // composed lowest-precedence to highest-precedence, and MDM has higher + // precedence than the legacy managed_config.toml file. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; - for (source, config) in managed_config_from_mdm - .map(|config| { - ( - RequirementSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, - ) - }) - .into_iter() - .chain(managed_config.map(|c| { + let layer_count = + usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some()); + let mut layers = Vec::with_capacity(layer_count); + for (source, config) in managed_config + .map(|c| { ( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, ) + }) + .into_iter() + .chain(managed_config_from_mdm.map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) })) { let legacy_config: LegacyManagedConfigToml = @@ -739,26 +755,56 @@ async fn load_requirements_from_legacy_scheme( ) })?; - merge_requirements_with_remote_sandbox_config( - config_requirements_toml, + layers.push(RequirementsLayerEntry::from_toml_value( source, - ConfigRequirementsToml::from(legacy_config), - ); + legacy_requirements_to_toml_value(legacy_config)?, + )); } - Ok(()) + Ok(layers) } -pub(super) fn merge_requirements_with_remote_sandbox_config( - target: &mut ConfigRequirementsWithSources, - source: RequirementSource, - mut requirements: ConfigRequirementsToml, -) { - if requirements.remote_sandbox_config.is_some() { - let host_name = crate::host_name(); - requirements.apply_remote_sandbox_config(host_name.as_deref()); +fn legacy_requirements_to_toml_value(legacy: LegacyManagedConfigToml) -> io::Result { + let LegacyManagedConfigToml { + approval_policy, + approvals_reviewer, + sandbox_mode, + } = legacy; + let mut table = toml::map::Map::new(); + if let Some(approval_policy) = approval_policy { + table.insert( + "allowed_approval_policies".to_string(), + toml_value_from_serializable(vec![approval_policy])?, + ); } - target.merge_unset_fields(source, requirements); + if let Some(approvals_reviewer) = approvals_reviewer { + let mut allowed_reviewers = vec![approvals_reviewer]; + if approvals_reviewer == ApprovalsReviewer::AutoReview { + allowed_reviewers.push(ApprovalsReviewer::User); + } + table.insert( + "allowed_approvals_reviewers".to_string(), + toml_value_from_serializable(allowed_reviewers)?, + ); + } + if let Some(sandbox_mode) = sandbox_mode { + let required_mode: SandboxModeRequirement = sandbox_mode.into(); + // Allowing read-only is a requirement for Codex to function correctly. + // So in this backfill path, we append read-only if it's not already specified. + let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; + if required_mode != SandboxModeRequirement::ReadOnly { + allowed_modes.push(required_mode); + } + table.insert( + "allowed_sandbox_modes".to_string(), + toml_value_from_serializable(allowed_modes)?, + ); + } + Ok(TomlValue::Table(table)) +} + +fn toml_value_from_serializable(value: T) -> io::Result { + TomlValue::try_from(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } struct ProjectTrustContext { @@ -1342,39 +1388,6 @@ struct LegacyManagedConfigToml { sandbox_mode: Option, } -impl From for ConfigRequirementsToml { - fn from(legacy: LegacyManagedConfigToml) -> Self { - let mut config_requirements_toml = ConfigRequirementsToml::default(); - - let LegacyManagedConfigToml { - approval_policy, - approvals_reviewer, - sandbox_mode, - } = legacy; - if let Some(approval_policy) = approval_policy { - config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); - } - if let Some(approvals_reviewer) = approvals_reviewer { - let mut allowed_reviewers = vec![approvals_reviewer]; - if approvals_reviewer == ApprovalsReviewer::AutoReview { - allowed_reviewers.push(ApprovalsReviewer::User); - } - config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); - } - if let Some(sandbox_mode) = sandbox_mode { - let required_mode: SandboxModeRequirement = sandbox_mode.into(); - // Allowing read-only is a requirement for Codex to function correctly. - // So in this backfill path, we append read-only if it's not already specified. - let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; - if required_mode != SandboxModeRequirement::ReadOnly { - allowed_modes.push(required_mode); - } - config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); - } - config_requirements_toml - } -} - // Cannot name this `mod tests` because of tests.rs in this folder. #[cfg(test)] mod unit_tests { @@ -1421,54 +1434,63 @@ foo = "xyzzy" } #[test] - fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { + fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_sandbox_modes, - Some(vec![ - SandboxModeRequirement::ReadOnly, - SandboxModeRequirement::WorkspaceWrite - ]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_sandbox_modes".to_string(), + TomlValue::Array(vec![ + TomlValue::String("read-only".to_string()), + TomlValue::String("workspace-write".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { + fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![ + TomlValue::String("guardian_subagent".to_string()), + TomlValue::String("user".to_string()), + ]), + )])) ); + Ok(()) } #[test] - fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { + fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() -> io::Result<()> { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, }; - let requirements = ConfigRequirementsToml::from(legacy); - assert_eq!( - requirements.allowed_approvals_reviewers, - Some(vec![ApprovalsReviewer::User]) + legacy_requirements_to_toml_value(legacy)?, + TomlValue::Table(toml::map::Map::from_iter([( + "allowed_approvals_reviewers".to_string(), + TomlValue::Array(vec![TomlValue::String("user".to_string())]), + )])) ); + Ok(()) } #[cfg(windows)] diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 9d4511a8a6..6492f9a0c0 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -128,7 +128,6 @@ model = "gpt-work" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -187,7 +186,6 @@ model = "gpt-main" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await @@ -244,7 +242,6 @@ model = "gpt-dev" /*cwd*/ None, &[], overrides, - CloudRequirementsLoader::default(), &crate::NoopThreadConfigLoader, ) .await diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index 0baec2a5cc..988e21e539 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -30,11 +30,18 @@ impl PermissionsToml { self.entries.is_empty() } + /// Resolve `profile_name` and all of its `extends` ancestors into one TOML + /// profile. + /// + /// Parent profiles are merged before their children, so child keys override + /// matching parent keys before callers compile the profile into runtime + /// permissions. The returned profile keeps the selected profile's + /// declaration metadata, such as `description` and `extends`. pub fn resolve_profile( &self, profile_name: &str, mut parent_profile: F, - ) -> Result + ) -> Result where F: FnMut(&str) -> Option, { @@ -96,10 +103,7 @@ impl PermissionsToml { .into_iter() .rev() .try_fold(profile, merge_permission_profiles)?; - return Ok(ResolvedPermissionProfileToml { - profile, - inherited_profile_names: profile_names.into_iter().skip(1).collect(), - }); + return Ok(profile); } } } @@ -114,17 +118,6 @@ pub struct PermissionProfileToml { pub network: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedPermissionProfileToml { - pub profile: PermissionProfileToml, - /// Names of profiles inherited while resolving `profile`, ordered from the - /// selected profile's direct parent to the farthest ancestor. - /// - /// Callers use this to preserve which built-in baseline contributed the - /// resolved permissions after the parent profiles have been merged away. - pub inherited_profile_names: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PermissionProfileResolutionError { #[error("default_permissions refers to undefined profile `{profile_name}`")] diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e4d03c3d3c..d5f2b050c9 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,6 +5,7 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::CloudConfigBundleLoader; use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -21,6 +22,7 @@ use toml::Value as TomlValue; pub struct ConfigLoadOptions { pub loader_overrides: LoaderOverrides, pub strict_config: bool, + pub cloud_config_bundle: CloudConfigBundleLoader, } impl From for ConfigLoadOptions { @@ -28,6 +30,7 @@ impl From for ConfigLoadOptions { Self { loader_overrides, strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), } } } @@ -471,8 +474,8 @@ impl ConfigLayerStack { /// Returns the merged config-layer view. /// - /// This only merges ordinary config layers and does not apply requirements - /// such as cloud requirements. + /// This only merges ordinary config layers. Requirements are composed and + /// tracked separately. pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.get_layers( diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs index fb64458e07..c1523c706c 100644 --- a/codex-rs/config/src/strict_config.rs +++ b/codex-rs/config/src/strict_config.rs @@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields( ) } -#[cfg(any(target_os = "macos", test))] pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( source_name: &str, contents: &str, diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs new file mode 100644 index 0000000000..44627f3892 --- /dev/null +++ b/codex-rs/config/src/test_support.rs @@ -0,0 +1,80 @@ +//! Test-only helpers exposed for cross-crate integration tests. +//! +//! Production code should not depend on this module. + +use crate::CloudConfigBundle; +use crate::CloudConfigBundleLoader; +use crate::CloudConfigFragment; +use crate::CloudRequirementsFragment; + +#[derive(Debug, Clone, Default)] +pub struct CloudConfigBundleFixture { + bundle: CloudConfigBundle, +} + +impl CloudConfigBundleFixture { + pub fn enterprise_requirement(contents: impl Into) -> Self { + Self::default().add_enterprise_requirement(contents) + } + + pub fn enterprise_config(contents: impl Into) -> Self { + Self::default().add_enterprise_config(contents) + } + + pub fn loader_with_enterprise_requirement( + contents: impl Into, + ) -> CloudConfigBundleLoader { + Self::enterprise_requirement(contents).into_loader() + } + + pub fn loader_with_enterprise_config(contents: impl Into) -> CloudConfigBundleLoader { + Self::enterprise_config(contents).into_loader() + } + + pub fn add_enterprise_requirement(mut self, contents: impl Into) -> Self { + let index = self.bundle.requirements_toml.enterprise_managed.len() + 1; + self.bundle + .requirements_toml + .enterprise_managed + .push(CloudRequirementsFragment { + id: format!("req_{index}"), + name: if index == 1 { + "Base requirements".to_string() + } else { + format!("Requirements {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn add_enterprise_config(mut self, contents: impl Into) -> Self { + let index = self.bundle.config_toml.enterprise_managed.len() + 1; + self.bundle + .config_toml + .enterprise_managed + .push(CloudConfigFragment { + id: format!("cfg_{index}"), + name: if index == 1 { + "Base config".to_string() + } else { + format!("Config {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn into_bundle(self) -> CloudConfigBundle { + self.bundle + } + + pub fn into_loader(self) -> CloudConfigBundleLoader { + let bundle = self.into_bundle(); + CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) }) + } +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/test_support_tests.rs b/codex-rs/config/src/test_support_tests.rs new file mode 100644 index 0000000000..379d6c2be2 --- /dev/null +++ b/codex-rs/config/src/test_support_tests.rs @@ -0,0 +1,25 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn adds_enterprise_requirements_in_order() { + let bundle = CloudConfigBundleFixture::enterprise_requirement("first") + .add_enterprise_requirement("second") + .into_bundle(); + + assert_eq!( + bundle.requirements_toml.enterprise_managed, + vec![ + CloudRequirementsFragment { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + contents: "first".to_string(), + }, + CloudRequirementsFragment { + id: "req_2".to_string(), + name: "Requirements 2".to_string(), + contents: "second".to_string(), + }, + ] + ); +} diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 9e37a14312..07f1fd4f26 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -3,7 +3,6 @@ use std::path::Path; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; -use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; use codex_config::loader::load_config_layers_state; @@ -106,7 +105,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin Some(cwd), &[], LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::default(), &NoopThreadConfigLoader, ) .await diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3132420e4c..678a6e5d10 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -755,6 +755,7 @@ async fn handle_request_permissions( ) { let call_id = event.call_id; let args = RequestPermissionsArgs { + environment_id: event.environment_id, reason: event.reason, permissions: event.permissions, }; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 64e0c16416..7dabb09d33 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -183,9 +183,11 @@ async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() { #[tokio::test] async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { - let (parent_session, parent_ctx, rx_events) = + let (parent_session, mut parent_ctx, rx_events) = crate::session::tests::make_session_and_context_with_rx().await; *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let parent_ctx_mut = Arc::get_mut(&mut parent_ctx).expect("single turn context ref"); + parent_ctx_mut.environments.turn_environments[0].environment_id = "remote".to_string(); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); @@ -228,6 +230,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { RequestPermissionsEvent { call_id: request_call_id, turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, reason: Some("need access".to_string()), permissions: RequestPermissionProfile { @@ -252,6 +255,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { panic!("expected RequestPermissions event"); }; assert_eq!(request.call_id, call_id.clone()); + assert_eq!(request.environment_id.as_deref(), Some("remote")); assert_eq!(request.cwd, Some(delegated_cwd)); parent_session diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 11386e6bcd..a002ce20d6 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -240,6 +240,7 @@ async fn run_compact_task_inner_impl( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); @@ -257,6 +258,7 @@ async fn run_compact_task_inner_impl( tokio::time::sleep(delay).await; continue; } else { + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; return Err(e); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b4456dc717..c2ef417b9b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -138,6 +138,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0da3017f7d..eb10eb211d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -155,6 +155,7 @@ async fn run_remote_compact_task_inner( } attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { + sess.track_turn_codex_error(turn_context, &err); let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), ); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index d0097d7537..5edecebec0 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -3,27 +3,31 @@ use crate::config::ConfigOverrides; use crate::config::ConstraintError; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoadError; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigError; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStackOrdering; use codex_config::ConfigLoadError; +use codex_config::ConfigLoadOptions; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConfigRequirementsWithSources; use codex_config::FilesystemDenyReadPattern; use codex_config::LoaderOverrides; use codex_config::RequirementSource; +use codex_config::RequirementsLayerEntry; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::compose_requirements; use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -46,6 +50,22 @@ fn config_error_from_io(err: &std::io::Error) -> &ConfigError { .expect("expected ConfigLoadError") } +fn cloud_config_bundle_requirement_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + } +} + +async fn load_single_requirements_toml( + requirements_file: &AbsolutePathBuf, +) -> anyhow::Result { + let layer = load_requirements_toml(LOCAL_FS.as_ref(), requirements_file) + .await? + .expect("requirements.toml should load"); + Ok(compose_requirements(vec![layer])?.expect("requirements should be present")) +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -147,7 +167,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -179,7 +198,6 @@ invalid = ["#, ignore_user_config: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -210,7 +228,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: ignore_user_and_project_exec_policy_rules: true, ..Default::default() }, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -236,7 +253,6 @@ invalid = ["#; Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -288,7 +304,6 @@ async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requi Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -323,7 +338,6 @@ command = "python3 /tmp/user-hook.py" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -533,7 +547,6 @@ extra = true Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -567,7 +580,6 @@ async fn returns_empty_when_all_layers_missing() { Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -644,7 +656,6 @@ approval_policy = "on-failure" Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -707,7 +718,6 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { Some(cwd), &[("features.plugins".to_string(), TomlValue::Boolean(true))], overrides, - CloudRequirementsLoader::default(), &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() @@ -787,7 +797,6 @@ flag = false Some(cwd), &[] as &[(String, TomlValue)], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await @@ -891,7 +900,6 @@ allowed_sandbox_modes = ["read-only"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -953,7 +961,6 @@ allowed_approval_policies = ["never"] Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], loader_overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -990,14 +997,8 @@ personality = true ) .await?; - let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = + load_single_requirements_toml(&AbsolutePathBuf::try_from(requirements_file)?).await?; assert_eq!( config_requirements_toml @@ -1073,7 +1074,7 @@ personality = true #[cfg(target_os = "macos")] #[tokio::test] -async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::Result<()> { +async fn mdm_requirements_take_precedence_over_cloud_config_bundle() -> anyhow::Result<()> { use base64::Engine; let tmp = tempdir()?; @@ -1091,49 +1092,34 @@ allowed_approval_policies = ["on-request"] tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - loader_overrides, - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - })) - }), + ConfigLoadOptions { + loader_overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; assert_eq!( state.requirements().approval_policy.value(), - AskForApproval::Never + AskForApproval::OnRequest ); assert_eq!( state .requirements() .approval_policy - .can_set(&AskForApproval::OnRequest), + .can_set(&AskForApproval::Never), Err(ConstraintError::InvalidValue { field_name: "approval_policy", - candidate: "OnRequest".into(), - allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, }) ); @@ -1141,7 +1127,7 @@ allowed_approval_policies = ["on-request"] } #[tokio::test(flavor = "current_thread")] -async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { +async fn cloud_config_bundle_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; let requirements_file = tmp.path().join("requirements.toml"); tokio::fs::write( @@ -1152,38 +1138,20 @@ allowed_approval_policies = ["on-request"] ) .await?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - RequirementSource::CloudRequirements, - ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }, - ); - load_requirements_toml( + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_config_bundle_requirement_source(), + r#"allowed_approval_policies = ["never"]"#, + ), + ])? + .expect("requirements should be present"); assert_eq!( config_requirements_toml @@ -1197,7 +1165,7 @@ allowed_approval_policies = ["on-request"] .allowed_approval_policies .as_ref() .map(|sourced| sourced.source.clone()), - Some(RequirementSource::CloudRequirements) + Some(cloud_config_bundle_requirement_source()) ); Ok(()) @@ -1217,22 +1185,21 @@ allowed_sandbox_modes = ["read-only", "workspace-write"] ) .await?; - let cloud_source = RequirementSource::CloudRequirements; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - config_requirements_toml.merge_unset_fields( - cloud_source.clone(), - toml::from_str( - r#" -allowed_sandbox_modes = ["read-only"] -"#, - )?, - ); - load_requirements_toml( + let cloud_source = cloud_config_bundle_requirement_source(); + let system_layer = load_requirements_toml( LOCAL_FS.as_ref(), - &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, ) - .await?; + .await? + .expect("system requirements should load"); + let config_requirements_toml = compose_requirements(vec![ + system_layer, + RequirementsLayerEntry::from_toml( + cloud_source.clone(), + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ])? + .expect("requirements should be present"); let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( @@ -1266,13 +1233,7 @@ deny_read = ["./sensitive", "../shared/secret.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1320,13 +1281,7 @@ deny_read = ["./sensitive/**/*.txt"] .await?; let requirements_file = AbsolutePathBuf::try_from(requirements_file)?; - let mut config_requirements_toml = ConfigRequirementsWithSources::default(); - load_requirements_toml( - LOCAL_FS.as_ref(), - &mut config_requirements_toml, - &requirements_file, - ) - .await?; + let config_requirements_toml = load_single_requirements_toml(&requirements_file).await?; let permissions = config_requirements_toml .permissions @@ -1360,44 +1315,26 @@ deny_read = ["./sensitive/**/*.txt"] } #[tokio::test] -async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> { +async fn load_config_layers_includes_cloud_config_bundle() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = r#"allowed_approval_policies = ["never"]"#; + let expected: ConfigRequirementsToml = toml::from_str(requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1415,7 +1352,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> field_name: "approval_policy", candidate: "OnRequest".into(), allowed: "[Never]".into(), - requirement_source: RequirementSource::CloudRequirements, + requirement_source: cloud_config_bundle_requirement_source(), }) ); @@ -1680,6 +1617,88 @@ extends = ":workspace" Ok(()) } +#[tokio::test] +async fn load_config_layers_inserts_cloud_config_between_system_and_user() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"model = "user" +"#, + ) + .await?; + + let system_config_path = tmp.path().join("system_config.toml"); + tokio::fs::write( + &system_config_path, + r#"model = "system" +model_provider = "system-provider" +review_model = "system-review" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_config_path = Some(system_config_path.clone()); + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: overrides, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + r#"model = "cloud" +model_provider = "cloud-provider" +"#, + ), + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let merged = layers.effective_config(); + let table = merged.as_table().expect("merged config should be a table"); + assert_eq!(table.get("model"), Some(&TomlValue::String("user".into()))); + assert_eq!( + table.get("model_provider"), + Some(&TomlValue::String("cloud-provider".into())) + ); + assert_eq!( + table.get("review_model"), + Some(&TomlValue::String("system-review".into())) + ); + assert_eq!( + layers + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(&system_config_path)?, + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?, + profile: None, + }, + ] + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1706,18 +1725,15 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< overrides.system_requirements_path = Some(system_requirements_path); overrides.ignore_managed_requirements = true; - let cloud_requirements = CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::Never]), - ..Default::default() - })) - }); + let cloud_config_bundle = CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["never"]"#, + ); let mut config = ConfigBuilder::default() .codex_home(codex_home) .fallback_cwd(Some(cwd.to_path_buf())) .loader_overrides(overrides) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await?; @@ -1747,36 +1763,36 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result tokio::fs::create_dir_all(&managed_dir).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements = ConfigRequirementsToml { - hooks: Some(codex_config::ManagedHooksRequirementsToml { - managed_dir: Some(managed_dir.clone()), - windows_managed_dir: None, - hooks: codex_config::HookEventsToml { - pre_tool_use: vec![codex_config::MatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![codex_config::HookHandlerConfig::Command { - command: format!("python3 {}/pre.py", managed_dir.display()), - command_windows: None, - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - ..Default::default() - }, - }), - ..ConfigRequirementsToml::default() - }; - let expected = requirements.clone(); - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + let requirements = format!( + r#" +[hooks] +managed_dir = '{}' + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = 'python3 {}/pre.py' +timeout = 10 +statusMessage = "checking" +"#, + managed_dir.display(), + managed_dir.display() + ); + let expected: ConfigRequirementsToml = toml::from_str(&requirements)?; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1788,12 +1804,94 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result .managed_hooks .as_ref() .map(|hooks| hooks.source.clone()), - Some(Some(RequirementSource::CloudRequirements)) + Some(Some(cloud_config_bundle_requirement_source())) ); Ok(()) } +#[tokio::test] +async fn load_config_layers_resolves_relative_bundle_requirements_paths_against_codex_home() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = r#" +[permissions.filesystem] +deny_read = ["secrets/**"] +"#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + cloud_config_bundle, + ..Default::default() + }, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let permissions = layers + .requirements_toml() + .permissions + .clone() + .expect("permissions requirements should load"); + let filesystem = permissions + .filesystem + .expect("filesystem requirements should load"); + + assert_eq!( + filesystem.deny_read, + Some(vec![ + FilesystemDenyReadPattern::from_input(&format!("{}/secrets/**", codex_home.display())) + .expect("bundle requirements path should resolve against codex_home") + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cloud_config_key() { + let tmp = tempdir().expect("tempdir"); + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("cwd"); + + let err = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + ConfigLoadOptions { + loader_overrides: LoaderOverrides::without_managed_config_for_tests(), + strict_config: true, + cloud_config_bundle: CloudConfigBundleFixture::loader_with_enterprise_config( + "unknown_key = true", + ), + }, + &codex_config::NoopThreadConfigLoader, + ) + .await + .expect_err("strict config should reject unknown cloud config keys"); + + assert!( + err.to_string() + .contains("unknown configuration field `unknown_key`"), + "{err:?}" + ); +} + #[tokio::test] async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1801,23 +1899,24 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: tokio::fs::create_dir_all(&codex_home).await?; let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; - let requirements: ConfigRequirementsToml = toml::from_str( - r#" + let requirements = r#" allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] - "#, - )?; - let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + "#; + let cloud_config_bundle = + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements); let layers = load_config_layers_state( LOCAL_FS.as_ref(), &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - cloud_requirements, + ConfigLoadOptions { + cloud_config_bundle, + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1841,7 +1940,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: } #[tokio::test] -async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyhow::Result<()> { +async fn load_config_layers_fails_when_cloud_config_bundle_loader_fails() -> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1852,21 +1951,23 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &codex_home, Some(cwd), &[] as &[(String, TomlValue)], - LoaderOverrides::default(), - CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new( - codex_config::CloudRequirementsLoadErrorCode::RequestFailed, - /*status_code*/ None, - "cloud requirements failed", - )) - }), + ConfigLoadOptions { + cloud_config_bundle: CloudConfigBundleLoader::new(async { + Err(CloudConfigBundleLoadError::new( + codex_config::CloudConfigBundleLoadErrorCode::RequestFailed, + /*status_code*/ None, + "cloud config bundle failed", + )) + }), + ..Default::default() + }, &codex_config::NoopThreadConfigLoader, ) .await - .expect_err("cloud requirements failure should fail closed"); + .expect_err("cloud config bundle failure should fail closed"); assert_eq!(err.kind(), std::io::ErrorKind::Other); - assert!(err.to_string().contains("cloud requirements failed")); + assert!(err.to_string().contains("cloud config bundle failed")); Ok(()) } @@ -1909,7 +2010,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1992,7 +2092,6 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2074,7 +2173,6 @@ async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_con Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2145,7 +2243,6 @@ async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2333,7 +2430,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2374,7 +2470,6 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2440,7 +2535,6 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2516,7 +2610,6 @@ profile = "ignored" Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2563,7 +2656,6 @@ profile = "ignored" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2652,7 +2744,6 @@ wire_api = "responses" Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2754,7 +2845,6 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st Some(AbsolutePathBuf::from_absolute_path(&project_root)?), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2920,7 +3010,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -2989,7 +3078,6 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown Some(cwd.clone()), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3050,7 +3138,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io Some(cwd), &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -3096,7 +3183,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() Some(cwd), &[] as &[(String, TomlValue)], LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 05cbb9828f..795057963e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -104,6 +104,7 @@ use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; use rmcp::model::UrlElicitationCapability; +use codex_config::test_support::CloudConfigBundleFixture; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -1389,15 +1390,14 @@ async fn experimental_network_requirements_enable_proxy_without_feature() -> std let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - network: Some(codex_config::NetworkRequirementsToml { - enabled: Some(true), - ..Default::default() - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +"#, + ), + ) .build() .await?; @@ -1867,7 +1867,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -2357,7 +2357,13 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: description: None, extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), workspace_roots: None, - filesystem: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), network: Some(NetworkToml { enabled: Some(true), ..Default::default() @@ -2384,6 +2390,42 @@ async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io:: !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected profile extending :workspace to keep inherited :slash_tmp writes, policy: {policy:?}" + ); + assert!( + policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Read, + } + )), + "expected child :tmpdir read entry to replace the inherited write entry, policy: {policy:?}" + ); + assert!( + !policy.entries.iter().any(|entry| matches!( + entry, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + access: FileSystemAccessMode::Write, + } + )), + "expected inherited :tmpdir write entry to be removed, policy: {policy:?}" + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled @@ -3890,7 +3932,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { http_mcp("https://example.com/mcp"), ), ]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "sample@test".to_string(), @@ -3940,7 +3982,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() { #[test] fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]); - let source = RequirementSource::CloudRequirements; + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let requirements = Sourced::new( BTreeMap::from([( "other@test".to_string(), @@ -4343,7 +4385,7 @@ enabled = true } #[tokio::test] -async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { +async fn to_mcp_config_applies_plugin_mcp_cloud_config_bundle() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let plugin_root = codex_home .path() @@ -4380,27 +4422,16 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - plugins: Some(BTreeMap::from([( - "sample@test".to_string(), - codex_config::PluginRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "sample".to_string(), - McpServerRequirement { - identity: McpServerIdentity::Url { - url: "https://sample.example/mcp".to_string(), - }, - }, - )])), - }, - )])), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[plugins."sample@test".mcp_servers.sample.identity] +url = "https://sample.example/mcp" +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4421,7 +4452,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -4462,15 +4496,15 @@ enabled = true "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::new()), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[mcp_servers] +"#, + ), + ) .build() .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); @@ -4484,7 +4518,10 @@ enabled = true Some(( false, Some(McpServerDisabledReason::Requirements { - source: RequirementSource::CloudRequirements, + source: RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Base requirements".to_string(), + }, }) )) ); @@ -5102,7 +5139,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { Some(cwd), &Vec::new(), overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -5236,7 +5272,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { Some(cwd), &[("model".to_string(), TomlValue::String("cli".to_string()))], overrides, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -8958,12 +8993,11 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -8982,35 +9016,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - allow_managed_hooks_only: None, - allow_appshots: None, - computer_use: None, - windows: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - guardian_policy_config: None, - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9030,21 +9043,16 @@ sandbox = "unelevated" "#, )?; - let requirements = codex_config::ConfigRequirementsToml { - windows: Some(codex_config::WindowsRequirementsToml { - allowed_sandbox_implementations: Some(vec![ - codex_config::types::WindowsSandboxModeToml::Elevated, - ]), - }), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"[windows] +allowed_sandbox_implementations = ["elevated"] +"#, + ), + ) .build() .await?; @@ -9075,12 +9083,11 @@ sandbox_mode = "danger-full-access" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained yolo should require sandbox approval"); @@ -9110,12 +9117,11 @@ default_permissions = "dev" let err = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await .expect_err("requirements-constrained full-access profile should require sandbox approval"); @@ -9132,11 +9138,6 @@ default_permissions = "dev" async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9144,9 +9145,11 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( permission_profile: Some(PermissionProfile::Disabled), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9162,11 +9165,6 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( #[tokio::test] async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), - ..Default::default() - }; - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) @@ -9174,9 +9172,11 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_sandbox_modes = ["read-only"]"#, + ), + ) .build() .await?; @@ -9286,14 +9286,11 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_web_search_modes: Some(vec![ - codex_config::WebSearchModeRequirement::Cached, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_web_search_modes = ["cached"]"#, + ), + ) .build() .await?; @@ -9327,12 +9324,11 @@ trust_level = "untrusted" let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; @@ -9356,12 +9352,11 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approval_policies = ["on-request"]"#, + ), + ) .build() .await?; assert_eq!( @@ -9377,17 +9372,15 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9411,14 +9404,14 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("auto_review".to_string(), false)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +auto_review = false +"#, + ), + ) .build() .await?; @@ -9433,17 +9426,15 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("in_app_browser".to_string(), false), - ("browser_use".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +in_app_browser = false +browser_use = false +"#, + ), + ) .build() .await?; @@ -9539,17 +9530,15 @@ shell_tool = true let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -9659,12 +9648,11 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9685,12 +9673,11 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) .build() .await?; @@ -9706,6 +9693,37 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } +#[tokio::test] +async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let selected_config = codex_home.path().join("default.config.toml"); + std::fs::write( + &selected_config, + r#"approvals_reviewer = "user" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("default".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["guardian_subagent"]"#, + ), + ) + .build() + .await?; + + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -9719,15 +9737,11 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ - ApprovalsReviewer::User, - ApprovalsReviewer::AutoReview, - ]), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["user", "guardian_subagent"]"#, + ), + ) .build() .await?; @@ -10213,17 +10227,15 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([ - ("personality".to_string(), true), - ("shell_tool".to_string(), false), - ]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +personality = true +shell_tool = false +"#, + ), + ) .build() .await?; @@ -10249,14 +10261,14 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<( let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("collab".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +collab = true +"#, + ), + ) .build() .await?; @@ -10279,14 +10291,14 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("made_up_feature".to_string(), true)]), - }), - ..Default::default() - })) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +made_up_feature = true +"#, + ), + ) .build() .await?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 797db9cb75..25f76b2b96 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -7,7 +7,7 @@ use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; @@ -1112,7 +1112,7 @@ pub struct ConfigBuilder { harness_overrides: Option, loader_overrides: Option, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, thread_config_loader: Option>, fallback_cwd: Option, } @@ -1143,8 +1143,8 @@ impl ConfigBuilder { self } - pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = cloud_requirements; + pub fn cloud_config_bundle(mut self, cloud_config_bundle: CloudConfigBundleLoader) -> Self { + self.cloud_config_bundle = cloud_config_bundle; self } @@ -1173,7 +1173,7 @@ impl ConfigBuilder { harness_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, thread_config_loader, fallback_cwd, } = self; @@ -1198,8 +1198,8 @@ impl ConfigBuilder { ConfigLoadOptions { loader_overrides, strict_config, + cloud_config_bundle, }, - cloud_requirements, thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), @@ -1623,7 +1623,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options( cwd.cloned(), &cli_overrides, options, - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; @@ -1834,7 +1833,6 @@ pub async fn load_global_mcp_servers( cwd, &cli_overrides, LoaderOverrides::default(), - CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) .await?; diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index a8546858f1..f683d9c7eb 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; @@ -13,7 +14,6 @@ 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::ResolvedPermissionProfileToml; use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; @@ -194,27 +194,132 @@ pub(crate) fn apply_network_proxy_feature_config( pub(crate) fn resolve_permission_profile( permissions: &PermissionsToml, profile_name: &str, -) -> io::Result { +) -> io::Result { permissions - .resolve_profile(profile_name, extensible_builtin_parent_profile_marker) + .resolve_profile(profile_name, extensible_builtin_parent_profile) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string())) } -/// Built-in parents provide their runtime permissions below. Resolution only -/// needs an empty profile marker so inheritance can terminate while preserving -/// the built-in parent id in `inherited_profile_names`. -fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option { - matches!( - profile_name, - BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE - ) - .then_some(PermissionProfileToml { +fn extensible_builtin_parent_profile(profile_name: &str) -> Option { + let file_system = match profile_name { + BUILT_IN_READ_ONLY_PROFILE => FileSystemSandboxPolicy::read_only(), + BUILT_IN_WORKSPACE_PROFILE => FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ), + _ => return None, + }; + Some(permission_profile_toml_from_file_system_policy(file_system)) +} + +fn permission_profile_toml_from_file_system_policy( + file_system: FileSystemSandboxPolicy, +) -> PermissionProfileToml { + let mut filesystem = FilesystemPermissionsToml { + glob_scan_max_depth: file_system.glob_scan_max_depth, + entries: BTreeMap::new(), + }; + for entry in file_system.entries { + insert_filesystem_permission_toml(&mut filesystem.entries, entry); + } + PermissionProfileToml { description: None, extends: None, workspace_roots: None, - filesystem: None, + filesystem: Some(filesystem), network: None, - }) + } +} + +fn insert_filesystem_permission_toml( + entries: &mut BTreeMap, + entry: FileSystemSandboxEntry, +) { + match entry.path { + FileSystemPath::Path { path } => { + entries.insert( + path.into_path_buf().to_string_lossy().into_owned(), + FilesystemPermissionToml::Access(entry.access), + ); + } + FileSystemPath::GlobPattern { pattern } => { + entries.insert(pattern, FilesystemPermissionToml::Access(entry.access)); + } + FileSystemPath::Special { value } => { + insert_special_filesystem_permission_toml(entries, value, entry.access); + } + } +} + +fn insert_special_filesystem_permission_toml( + entries: &mut BTreeMap, + value: FileSystemSpecialPath, + access: FileSystemAccessMode, +) { + match value { + FileSystemSpecialPath::Root => { + entries.insert( + ":root".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Minimal => { + entries.insert( + ":minimal".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::ProjectRoots { subpath } => { + insert_scoped_filesystem_permission_toml( + entries, + ":workspace_roots".to_string(), + subpath.unwrap_or_else(|| PathBuf::from(".")), + access, + ); + } + FileSystemSpecialPath::Tmpdir => { + entries.insert( + ":tmpdir".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::SlashTmp => { + entries.insert( + ":slash_tmp".to_string(), + FilesystemPermissionToml::Access(access), + ); + } + FileSystemSpecialPath::Unknown { path, subpath } => { + if let Some(subpath) = subpath { + insert_scoped_filesystem_permission_toml(entries, path, subpath, access); + } else { + entries.insert(path, FilesystemPermissionToml::Access(access)); + } + } + }; +} + +fn insert_scoped_filesystem_permission_toml( + entries: &mut BTreeMap, + path: String, + subpath: PathBuf, + access: FileSystemAccessMode, +) { + let permission = entries + .entry(path) + .or_insert_with(|| FilesystemPermissionToml::Scoped(BTreeMap::new())); + match permission { + FilesystemPermissionToml::Scoped(scoped_entries) => { + scoped_entries.insert(subpath.to_string_lossy().into_owned(), access); + } + FilesystemPermissionToml::Access(_) => { + *permission = FilesystemPermissionToml::Scoped(BTreeMap::from([( + subpath.to_string_lossy().into_owned(), + access, + )])); + } + } } pub(crate) fn network_proxy_config_for_profile_selection( @@ -232,7 +337,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?.profile; + let profile = resolve_permission_profile(permissions, profile_name)?; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -244,25 +349,9 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - let ResolvedPermissionProfileToml { - profile, - inherited_profile_names, - } = resolve_permission_profile(permissions, profile_name)?; - let base_permissions = inherited_profile_names.iter().find_map(|name| { - match name.as_str() { - BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), - BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()), - _ => None, - } - .map(|profile| profile.to_runtime_permissions()) - }); - let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions - .unwrap_or_else(|| { - ( - FileSystemSandboxPolicy::restricted(Vec::new()), - NetworkSandboxPolicy::Restricted, - ) - }); + let profile = resolve_permission_profile(permissions, profile_name)?; + let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(Vec::new()); + let base_network_sandbox_policy = NetworkSandboxPolicy::Restricted; if let Some(filesystem) = profile.filesystem.as_ref() { if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( @@ -358,7 +447,7 @@ pub(crate) fn compile_permission_profile_workspace_roots( })?; let profile = resolve_permission_profile(permissions, profile_name)?; Ok(compile_workspace_roots( - profile.profile.workspace_roots.as_ref(), + profile.workspace_roots.as_ref(), policy_cwd, )) } @@ -693,6 +782,7 @@ fn parse_special_path(path: &str) -> Option { ":minimal" => Some(FileSystemSpecialPath::Minimal), ":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), + ":slash_tmp" => Some(FileSystemSpecialPath::SlashTmp), _ if path.starts_with(':') => { Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None)) } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 41f4179ba8..88a757181e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -326,8 +326,7 @@ allow_local_binding = true ) .expect("expected profile should deserialize"); - assert_eq!(resolved.profile, expected_profile); - assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); + assert_eq!(resolved, expected_profile); } #[test] diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index fd4b53dc1b..d5c3b3c348 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -5,10 +5,10 @@ use codex_config::AppRequirementToml; use codex_config::AppToolRequirementToml; use codex_config::AppToolsRequirementsToml; use codex_config::AppsRequirementsToml; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; @@ -455,15 +455,13 @@ approvals_reviewer = "user" "#, ) .expect("write config"); - let requirements = ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#"allowed_approvals_reviewers = ["auto_review"]"#, + ), + ) .build() .await .expect("config should build"); @@ -484,30 +482,19 @@ approvals_reviewer = "user" "#, ) .expect("write config"); - let requirements = ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ - ApprovalsReviewer::User, - ApprovalsReviewer::AutoReview, - ]), - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "calendar".to_string(), - AppRequirementToml { - allowed_approvals_reviewers: Some(BTreeMap::from([ - (ApprovalsReviewer::User, false), - (ApprovalsReviewer::AutoReview, true), - ])), - ..Default::default() - }, - )]), - }), - ..Default::default() - }; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +allowed_approvals_reviewers = ["user", "auto_review"] + +[apps.calendar.allowed_approvals_reviewers] +user = false +auto_review = true +"#, + ), + ) .build() .await .expect("config should build"); @@ -553,23 +540,27 @@ approvals_reviewer = "{global}" ), ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "calendar".to_string(), - AppRequirementToml { - allowed_approvals_reviewers: Some(overrides), - ..Default::default() - }, - )]), - }), - ..Default::default() - }; + let (user, auto_review) = ( + overrides + .get(&ApprovalsReviewer::User) + .copied() + .unwrap_or(false), + overrides + .get(&ApprovalsReviewer::AutoReview) + .copied() + .unwrap_or(false), + ); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(&format!( + r#" +[apps.calendar.allowed_approvals_reviewers] +user = {user} +auto_review = {auto_review} +"# + )), + ) .build() .await .expect("config should build"); @@ -652,7 +643,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { } #[tokio::test] -async fn cloud_requirements_disable_connector_overrides_user_apps_config() { +async fn cloud_config_bundle_disable_connector_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -663,25 +654,17 @@ enabled = true ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - ..Default::default() - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -703,29 +686,21 @@ enabled = true } #[tokio::test] -async fn cloud_requirements_disable_connector_applies_without_user_apps_table() { +async fn cloud_config_bundle_disable_connector_applies_without_user_apps_table() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write(codex_home.path().join(CONFIG_TOML_FILE), "").expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(AppsRequirementsToml { - apps: BTreeMap::from([( - "connector_123123".to_string(), - AppRequirementToml { - enabled: Some(false), - ..Default::default() - }, - )]), - }), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123] +enabled = false +"#, + ), + ) .build() .await .expect("config should build"); @@ -981,7 +956,7 @@ fn managed_app_tool_approval_uses_raw_tool_name() { } #[tokio::test] -async fn cloud_requirements_tool_approval_overrides_user_apps_config() { +async fn cloud_config_bundle_tool_approval_overrides_user_apps_config() { let codex_home = tempdir().expect("tempdir should succeed"); std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -992,21 +967,17 @@ approval_mode = "prompt" ) .expect("write config"); - let requirements = ConfigRequirementsToml { - apps: Some(app_tool_requirements( - "connector_123123", - "calendar/list_events", - AppToolApproval::Approve, - )), - ..Default::default() - }; - let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "approve" +"#, + ), + ) .build() .await .expect("config should build"); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index fed87cd75f..b8c5c98075 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; @@ -719,9 +718,7 @@ async fn run_review_on_session( .total_token_usage() .await .unwrap_or_default(); - // The legacy SandboxPolicy should match the PermissionProfile. let guardian_permission_profile = PermissionProfile::read_only(); - let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy(); let submit_result = run_before_review_deadline( deadline, @@ -736,7 +733,7 @@ async fn run_review_on_session( #[allow(deprecated)] cwd: Some(params.parent_turn.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(legacy_sandbox_policy), + sandbox_policy: None, permission_profile: Some(guardian_permission_profile), summary: Some(params.reasoning_summary), personality: params.personality, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 7d67ad7859..b91eaa26ca 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -10,7 +10,6 @@ use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; -use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::LoaderOverrides; @@ -60,7 +59,6 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec Result, - turn_context: &Arc, - call_id: String, - args: RequestPermissionsArgs, - cancellation_token: CancellationToken, - ) -> Option { - self.request_permissions_for_cwd( - turn_context, - call_id, - args, - #[allow(deprecated)] - turn_context.cwd.clone(), - cancellation_token, - ) - .await - } - #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub(crate) async fn request_permissions_for_cwd( + pub(crate) async fn request_permissions_for_environment( self: &Arc, turn_context: &Arc, call_id: String, args: RequestPermissionsArgs, - cwd: AbsolutePathBuf, + environment: TurnEnvironmentSelection, cancellation_token: CancellationToken, ) -> Option { match turn_context.as_ref().approval_policy.value() { @@ -2221,10 +2215,11 @@ impl Session { let response = Self::normalize_request_permissions_response( requested_permissions, response, - cwd.as_path(), + environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2242,7 +2237,7 @@ impl Session { PendingRequestPermissions { tx_response, requested_permissions: requested_permissions.clone(), - cwd: cwd.clone(), + environment: environment.clone(), }, ) } @@ -2256,10 +2251,11 @@ impl Session { let event = EventMsg::RequestPermissions(RequestPermissionsEvent { call_id: call_id.clone(), turn_id: turn_context.sub_id.clone(), + environment_id: Some(environment.environment_id.clone()), started_at_ms: now_unix_timestamp_ms(), reason: args.reason, permissions: requested_permissions, - cwd: Some(cwd), + cwd: Some(environment.cwd), }); self.send_event(turn_context.as_ref(), event).await; tokio::select! { @@ -2276,6 +2272,41 @@ impl Session { } } + pub(crate) async fn request_permissions_for_cwd( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + cancellation_token: CancellationToken, + ) -> Option { + let turn_environment = match args.environment_id.as_deref() { + Some(environment_id) => turn_context + .environments + .turn_environments + .iter() + .find(|environment| environment.environment_id == environment_id), + None => turn_context.environments.primary(), + }; + let Some(turn_environment) = turn_environment else { + return Some(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }); + }; + let mut environment = turn_environment.selection(); + environment.cwd = cwd; + self.request_permissions_for_environment( + turn_context, + call_id, + args, + environment, + cancellation_token, + ) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -2370,10 +2401,11 @@ impl Session { let response = Self::normalize_request_permissions_response( entry.requested_permissions, response, - entry.cwd.as_path(), + entry.environment.cwd.as_path(), ); self.record_granted_request_permissions_for_turn( &response, + &entry.environment.environment_id, originating_turn_state.as_ref(), ) .await; @@ -2417,6 +2449,7 @@ impl Session { async fn record_granted_request_permissions_for_turn( &self, response: &RequestPermissionsResponse, + environment_id: &str, originating_turn_state: Option<&Arc>>, ) { if response.permissions.is_empty() { @@ -2428,7 +2461,7 @@ impl Session { let mut ts = turn_state.lock().await; let permissions: AdditionalPermissionProfile = response.permissions.clone().into(); - ts.record_granted_permissions(permissions); + ts.record_granted_permissions(environment_id, permissions); if response.strict_auto_review { ts.enable_strict_auto_review(); } @@ -2436,7 +2469,10 @@ impl Session { } PermissionGrantScope::Session => { let mut state = self.state.lock().await; - state.record_granted_permissions(response.permissions.clone().into()); + state.record_granted_permissions( + environment_id, + response.permissions.clone().into(), + ); } } } @@ -2445,11 +2481,14 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn reads must stay consistent with the matching turn state" )] - pub(crate) async fn granted_turn_permissions(&self) -> Option { + pub(crate) async fn granted_turn_permissions( + &self, + environment_id: &str, + ) -> Option { let active = self.active_turn.lock().await; let active = active.as_ref()?; let ts = active.turn_state.lock().await; - ts.granted_permissions() + ts.granted_permissions(environment_id) } #[expect( @@ -2465,9 +2504,12 @@ impl Session { ts.strict_auto_review_enabled() } - pub(crate) async fn granted_session_permissions(&self) -> Option { + pub(crate) async fn granted_session_permissions( + &self, + environment_id: &str, + ) -> Option { let state = self.state.lock().await; - state.granted_permissions() + state.granted_permissions(environment_id) } #[expect( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d4b4942a88..a3e5558971 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -73,6 +73,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; +use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; @@ -1039,7 +1040,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an enabled: Some(true), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); requirements_toml.network = Some(codex_config::NetworkRequirementsToml { @@ -5155,7 +5156,12 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { ) .await; - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -5182,16 +5188,84 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; assert_eq!( - originating_turn_state.lock().await.granted_permissions(), + originating_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), Some(requested_permissions.into()) ); - assert_eq!(current_turn_state.lock().await.granted_permissions(), None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + current_turn_state + .lock() + .await + .granted_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID), + None + ); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); +} + +#[tokio::test] +async fn request_permission_grants_are_environment_keyed() { + let (session, _turn_context) = make_session_and_context().await; + let originating_active_turn = ActiveTurn::default(); + let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); + *session.active_turn.lock().await = Some(originating_active_turn); + + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + "remote", + Some(&originating_turn_state), + ) + .await; + + { + let turn_state = originating_turn_state.lock().await; + assert_eq!( + turn_state.granted_permissions("remote"), + Some(requested_permissions.clone().into()) + ); + assert_eq!(turn_state.granted_permissions("local"), None); + } + + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Session, + strict_auto_review: false, + }, + "remote", + /*originating_turn_state*/ None, + ) + .await; + + assert_eq!( + session.granted_session_permissions("remote").await, + Some(requested_permissions.into()) + ); + assert_eq!(session.granted_session_permissions("local").await, None); } #[tokio::test] @@ -5214,6 +5288,7 @@ async fn enable_strict_auto_review_for_turn_uses_originating_turn() { scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -5290,11 +5365,17 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let turn_context = Arc::clone(&turn_context); let call_id = call_id.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5303,6 +5384,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await @@ -5317,6 +5399,10 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); #[allow(deprecated)] let turn_cwd = turn_context.cwd.clone(); assert_eq!(request.cwd, Some(turn_cwd)); @@ -5333,6 +5419,142 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() assert_eq!(response, Some(expected_response)); } +#[tokio::test] +async fn request_permissions_tool_resolves_relative_paths_against_selected_environment() { + let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let environment_cwd = { + #[allow(deprecated)] + let legacy_cwd = turn_context.cwd.clone(); + legacy_cwd.join("request-permissions-environment") + }; + std::fs::create_dir_all(environment_cwd.as_path()).expect("create environment cwd"); + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single thread settings ref"); + turn_context_mut + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + })) + .expect("test setup should allow updating approval policy"); + turn_context_mut.environments.turn_environments[0].environment_id = "remote".to_string(); + turn_context_mut.environments.turn_environments[0].cwd = environment_cwd.clone(); + + let call_id = "call-1".to_string(); + let handler = RequestPermissionsHandler; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handle = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let tracker = Arc::clone(&tracker); + let call_id = call_id.clone(); + async move { + handler + .handle(ToolInvocation { + session, + turn: turn_context, + cancellation_token: CancellationToken::new(), + tracker, + call_id, + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "environment_id": "remote", + "reason": "need write", + "permissions": { + "file_system": { + "entries": [{ + "path": { + "type": "path", + "path": "relative.txt", + }, + "access": "write", + }], + }, + }, + }) + .to_string(), + }, + }) + .await + } + }); + + let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv()) + .await + .expect("request_permissions event timed out") + .expect("request_permissions event missing"); + let EventMsg::RequestPermissions(request) = request_event.msg else { + panic!("expected request_permissions event"); + }; + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: environment_cwd.join("relative.txt"), + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + ..Default::default() + }; + assert_eq!(request.environment_id.as_deref(), Some("remote")); + assert_eq!(request.permissions, expected_permissions); + + session + .notify_request_permissions_response( + &request.call_id, + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: request.permissions, + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + ) + .await; + tokio::time::timeout(StdDuration::from_secs(1), handle) + .await + .expect("request_permissions handler timed out") + .expect("request_permissions handler join error") + .expect("request_permissions handler should succeed"); +} + +#[tokio::test] +async fn request_permissions_tool_rejects_unknown_environment_id() { + let (session, turn_context) = make_session_and_context().await; + let result = RequestPermissionsHandler + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "call-1".to_string(), + tool_name: codex_tools::ToolName::plain("request_permissions"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ + "environment_id": "missing", + "permissions": { + "network": { + "enabled": true, + }, + }, + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = result else { + panic!("expected unknown environment id to be rejected"); + }; + assert_eq!(output, "unknown turn environment id `missing`"); +} + #[tokio::test] async fn request_permissions_response_materializes_session_cwd_grants_before_recording() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -5371,14 +5593,21 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let call_id = call_id.clone(); let requested_permissions = requested_permissions.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need cwd write".to_string()), permissions: requested_permissions, }, + environment, CancellationToken::new(), ) .await @@ -5392,6 +5621,10 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let EventMsg::RequestPermissions(request) = request_event.msg else { panic!("expected request_permissions event"); }; + assert_eq!( + request.environment_id.as_deref(), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); let request_cwd = request.cwd.clone().expect("request cwd"); session @@ -5425,7 +5658,9 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec assert_eq!(response, Some(expected_response)); assert_eq!( - session.granted_session_permissions().await, + session + .granted_session_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(expected_permissions.into()) ); } @@ -5449,11 +5684,17 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let session = Arc::new(session); let turn_context = Arc::new(turn_context); let call_id = "call-1".to_string(); + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = session - .request_permissions( + .request_permissions_for_environment( &turn_context, call_id, codex_protocol::request_permissions::RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { @@ -5462,6 +5703,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req ..RequestPermissionProfile::default() }, }, + environment, CancellationToken::new(), ) .await; @@ -6859,7 +7101,7 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )); let layers = config .config_layer_stack @@ -10450,7 +10692,12 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ); pretty_assertions::assert_eq!(output, expected); - pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None); + pretty_assertions::assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); // The rejection should not poison the non-escalated path for the same // command. Force DangerFullAccess so this check stays focused on approval diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 2977e6bdf5..d8b46c9999 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -124,15 +124,22 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }), ..RequestPermissionProfile::default() }; + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); let response = tokio::time::timeout( Duration::from_secs(45), - session.request_permissions( + session.request_permissions_for_environment( &turn_context, "perm-call-1".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions.clone(), }, + environment, CancellationToken::new(), ), ) @@ -148,7 +155,9 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { }) ); assert_eq!( - session.granted_turn_permissions().await, + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, Some(requested_permissions.into()) ); @@ -211,14 +220,21 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { let requested_permissions = requested_permissions.clone(); let cancellation_token = cancellation_token.clone(); async move { + let environment = turn_context + .environments + .primary() + .expect("primary environment") + .selection(); session - .request_permissions( + .request_permissions_for_environment( &turn_context, "perm-call-cancelled".to_string(), RequestPermissionsArgs { + environment_id: None, reason: Some("need network".to_string()), permissions: requested_permissions, }, + environment, cancellation_token, ) .await @@ -246,7 +262,12 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { .expect("request_permissions should stop when cancelled") .expect("request_permissions task should not panic"); assert_eq!(response, None); - assert_eq!(session.granted_turn_permissions().await, None); + assert_eq!( + session + .granted_turn_permissions(codex_exec_server::LOCAL_ENVIRONMENT_ID) + .await, + None + ); } #[tokio::test] @@ -380,6 +401,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ scope: PermissionGrantScope::Turn, strict_auto_review: true, }, + codex_exec_server::LOCAL_ENVIRONMENT_ID, Some(&originating_turn_state), ) .await; @@ -564,12 +586,15 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let mut active_turn = session.active_turn.lock().await; let active_turn = active_turn.as_mut().expect("active turn"); let mut turn_state = active_turn.turn_state.lock().await; - turn_state.record_granted_permissions(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }); + turn_state.record_granted_permissions( + codex_exec_server::LOCAL_ENVIRONMENT_ID, + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ); } let session = Arc::new(session); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d1e9e59f70..498e65c7b9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -369,7 +369,7 @@ pub(crate) async fn run_turn( // Aborted turn is reported via a different event. break; } - Err(CodexErr::InvalidImageRequest()) => { + Err(codex_error @ CodexErr::InvalidImageRequest()) => { { let mut state = sess.state.lock().await; error_or_panic( @@ -380,6 +380,7 @@ pub(crate) async fn run_turn( } } + sess.track_turn_codex_error(turn_context.as_ref(), &codex_error); let error = CodexErrorInfo::BadRequest; sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; @@ -405,6 +406,7 @@ pub(crate) async fn run_turn( { warn!("failed to usage-limit active goal after usage-limit error: {err}"); } + sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation @@ -638,6 +640,7 @@ async fn track_turn_resolved_config_analytics( sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), collaboration_mode: turn_context.collaboration_mode.mode, personality: turn_context.personality, + workspace_kind: turn_context.turn_metadata_state.workspace_kind(), is_first_turn, }); } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 2663943f82..024f1904cc 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -3,6 +3,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; +use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -37,7 +38,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_sources: VecDeque, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, next_turn_is_first: bool, } @@ -57,7 +58,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_sources: VecDeque::new(), - granted_permissions: None, + granted_permissions_by_environment_id: HashMap::new(), next_turn_is_first: true, } } @@ -235,13 +236,29 @@ impl SessionState { self.pending_session_start_sources.pop_front() } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index fe09235197..eaf26ba853 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,6 +1,5 @@ //! Turn-scoped state and active turn metadata scaffolding. -use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,11 +9,12 @@ use tokio_util::task::AbortOnDropHandle; use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_sandboxing::policy_transforms::merge_permission_profiles; use rmcp::model::RequestId; use tokio::sync::oneshot; @@ -90,7 +90,7 @@ pub(crate) struct TurnState { pending_dynamic_tools: HashMap>, pub(crate) pending_input: TurnInputQueue, mailbox_delivery_phase: MailboxDeliveryPhase, - granted_permissions: Option, + granted_permissions_by_environment_id: HashMap, strict_auto_review_enabled: bool, pub(crate) tool_calls: u64, pub(crate) has_memory_citation: bool, @@ -100,7 +100,7 @@ pub(crate) struct TurnState { pub(crate) struct PendingRequestPermissions { pub(crate) tx_response: oneshot::Sender, pub(crate) requested_permissions: RequestPermissionProfile, - pub(crate) cwd: AbsolutePathBuf, + pub(crate) environment: TurnEnvironmentSelection, } impl TurnState { @@ -204,13 +204,29 @@ impl TurnState { self.mailbox_delivery_phase = phase; } - pub(crate) fn record_granted_permissions(&mut self, permissions: AdditionalPermissionProfile) { - self.granted_permissions = - merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); + pub(crate) fn record_granted_permissions( + &mut self, + environment_id: &str, + permissions: AdditionalPermissionProfile, + ) { + let granted_permissions = merge_permission_profiles( + self.granted_permissions_by_environment_id + .get(environment_id), + Some(&permissions), + ); + if let Some(granted_permissions) = granted_permissions { + self.granted_permissions_by_environment_id + .insert(environment_id.to_string(), granted_permissions); + } } - pub(crate) fn granted_permissions(&self) -> Option { - self.granted_permissions.clone() + pub(crate) fn granted_permissions( + &self, + environment_id: &str, + ) -> Option { + self.granted_permissions_by_environment_id + .get(environment_id) + .cloned() } pub(crate) fn enable_strict_auto_review(&mut self) { diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bb1f5fba65..7914c8d482 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -131,6 +131,7 @@ pub(crate) async fn persist_image_generation_item( turn_context: &TurnContext, image_item: &mut ImageGenerationItem, ) -> Option { + image_item.saved_path = None; let session_id = sess.conversation_id.to_string(); match save_image_generation_result( &turn_context.config.codex_home, @@ -163,15 +164,12 @@ pub(crate) async fn persist_image_generation_item( } } -pub(crate) async fn finalize_image_generation_item( +async fn record_image_generation_instructions( sess: &Session, turn_context: &TurnContext, - image_item: &mut ImageGenerationItem, + image_item: &ImageGenerationItem, ) { - if persist_image_generation_item(sess, turn_context, image_item) - .await - .is_none() - { + if image_item.saved_path.is_none() { return; } let session_id = sess.conversation_id.to_string(); @@ -530,27 +528,16 @@ pub(crate) async fn handle_non_tool_response_item( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } => { let mut turn_item = parse_turn_item(item)?; - if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { - apply_turn_item_contributors(sess, turn_store, &mut turn_item).await; - } - if let TurnItem::AgentMessage(agent_message) = &mut turn_item { - let combined = agent_message - .content - .iter() - .map(|entry| match entry { - codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), - }) - .collect::(); - let (stripped, memory_citation) = - strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); - agent_message.content = - vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; - if agent_message.memory_citation.is_none() { - agent_message.memory_citation = memory_citation; - } - } - if let TurnItem::ImageGeneration(image_item) = &mut turn_item { - finalize_image_generation_item(sess, turn_context, image_item).await; + finalize_turn_item( + sess, + turn_context, + contributor_policy, + &mut turn_item, + plan_mode, + ) + .await; + if let TurnItem::ImageGeneration(image_item) = &turn_item { + record_image_generation_instructions(sess, turn_context, image_item).await; } Some(turn_item) } @@ -564,6 +551,37 @@ pub(crate) async fn handle_non_tool_response_item( } } +pub(crate) async fn finalize_turn_item( + sess: &Session, + turn_context: &TurnContext, + contributor_policy: TurnItemContributorPolicy<'_>, + turn_item: &mut TurnItem, + plan_mode: bool, +) { + if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { + apply_turn_item_contributors(sess, turn_store, turn_item).await; + } + if let TurnItem::AgentMessage(agent_message) = &mut *turn_item { + let combined = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + let (stripped, memory_citation) = + strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); + agent_message.content = + vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; + if agent_message.memory_citation.is_none() { + agent_message.memory_citation = memory_citation; + } + } + if let TurnItem::ImageGeneration(image_item) = &mut *turn_item { + persist_image_generation_item(sess, turn_context, image_item).await; + } +} + pub(crate) fn last_assistant_message_from_item( item: &ResponseItem, plan_mode: bool, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index bfc3ab94b3..c12ba0517e 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -265,6 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option { async fn effective_patch_permissions( session: &Session, turn: &TurnContext, + environment_id: &str, action: &ApplyPatchAction, cwd: &AbsolutePathBuf, ) -> ( @@ -274,8 +275,14 @@ async fn effective_patch_permissions( ) { let file_paths = file_paths_for_action(action); let granted_permissions = merge_permission_profiles( - session.granted_session_permissions().await.as_ref(), - session.granted_turn_permissions().await.as_ref(), + session + .granted_session_permissions(environment_id) + .await + .as_ref(), + session + .granted_turn_permissions(environment_id) + .await + .as_ref(), ); let base_file_system_sandbox_policy = turn.file_system_sandbox_policy(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( @@ -284,6 +291,7 @@ async fn effective_patch_permissions( ); let effective_additional_permissions = apply_granted_turn_permissions( session, + environment_id, cwd.as_path(), crate::sandboxing::SandboxPermissions::UseDefault, write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd), @@ -353,8 +361,14 @@ impl ToolExecutor for ApplyPatchHandler { { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, &cwd) - .await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + &cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { @@ -506,7 +520,14 @@ pub(crate) async fn intercept_apply_patch( { codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = - effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, cwd).await; + effective_patch_permissions( + session.as_ref(), + turn.as_ref(), + &turn_environment.environment_id, + &changes, + cwd, + ) + .await; match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes) .await { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 4ad88ac5bb..0a1e2e58c8 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -4,7 +4,6 @@ use std::sync::Weak; use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; -use codex_tools::ImageGenerationCompletionFuture; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSearchInfo; @@ -12,12 +11,11 @@ use codex_tools::ToolSpec; use codex_tools::TurnItemEmissionFuture; use codex_tools::TurnItemEmitter; -use crate::context::ContextualUserFragment; -use crate::context::ImageGenerationInstructions; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use crate::stream_events_utils::persist_image_generation_item; +use crate::stream_events_utils::TurnItemContributorPolicy; +use crate::stream_events_utils::finalize_turn_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -76,6 +74,10 @@ struct CoreTurnItemEmitter { fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem { match item { ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item), + ExtensionTurnItem::ImageGeneration(mut item) => { + item.saved_path = None; + TurnItem::ImageGeneration(item) + } } } @@ -85,8 +87,9 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); - session.emit_turn_item_started(turn.as_ref(), &item).await; + session + .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item)) + .await; }) } @@ -95,54 +98,18 @@ impl TurnItemEmitter for CoreTurnItemEmitter { let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { return; }; - let item = extension_turn_item(item); + let mut item = extension_turn_item(item); + finalize_turn_item( + session.as_ref(), + turn.as_ref(), + TurnItemContributorPolicy::Run(turn.extension_data.as_ref()), + &mut item, + turn.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan, + ) + .await; session.emit_turn_item_completed(turn.as_ref(), item).await; }) } - - fn image_generation_completed<'a>( - &'a self, - call_id: String, - prompt: String, - result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(async move { - let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { - return None; - }; - let mut item = codex_protocol::items::ImageGenerationItem { - id: call_id, - status: "completed".to_string(), - revised_prompt: Some(prompt), - result, - saved_path: None, - }; - let output_hint = - persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item) - .await - .map(|saved_path| { - let output_dir = saved_path - .parent() - .unwrap_or_else(|| turn.config.codex_home.clone()); - ImageGenerationInstructions::new(output_dir.display(), saved_path.display()) - .body() - }); - let started_item = codex_protocol::items::ImageGenerationItem { - id: item.id.clone(), - status: "in_progress".to_string(), - revised_prompt: None, - result: String::new(), - saved_path: None, - }; - session - .emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item)) - .await; - session - .emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item)) - .await; - output_hint - }) - } } async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { @@ -167,6 +134,8 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { mod tests { use std::sync::Arc; + use codex_extension_api::ExtensionData; + use codex_extension_api::TurnItemContributor; use codex_protocol::items::TurnItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; @@ -174,10 +143,13 @@ mod tests { use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::EventMsg; use codex_tools::ExtensionTurnItem; + use codex_utils_absolute_path::test_support::PathExt; + use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use serde_json::json; use tokio::sync::Mutex; + use super::CoreTurnItemEmitter; use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; @@ -409,8 +381,54 @@ mod tests { assert_eq!(end.action, expected.action); } - struct ImageGenerationExtensionExecutor { - output_hint: Arc>>, + struct ImageGenerationExtensionExecutor; + + #[derive(Debug)] + struct ExtensionTurnItemContributorRan; + + struct RecordExtensionTurnItemContributor; + + #[async_trait::async_trait] + impl TurnItemContributor for RecordExtensionTurnItemContributor { + async fn contribute( + &self, + _thread_store: &ExtensionData, + turn_store: &ExtensionData, + _item: &mut TurnItem, + ) -> Result<(), String> { + turn_store.insert(ExtensionTurnItemContributorRan); + Ok(()) + } + } + + #[tokio::test] + async fn extension_completion_runs_turn_item_contributors() { + let (mut session, turn) = crate::session::tests::make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor)); + session.services.extensions = Arc::new(builder.build()); + let session = Arc::new(session); + let turn = Arc::new(turn); + let emitter = CoreTurnItemEmitter { + session: Arc::downgrade(&session), + turn: Arc::downgrade(&turn), + }; + + codex_tools::TurnItemEmitter::emit_completed( + &emitter, + ExtensionTurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "contributors".to_string(), + action: WebSearchAction::Other, + }), + ) + .await; + + assert!( + turn.extension_data + .get::() + .is_some() + ); } #[async_trait::async_trait] @@ -434,15 +452,28 @@ mod tests { &self, call: codex_tools::ToolCall, ) -> Result, codex_tools::FunctionCallError> { - let output_hint = call - .turn_item_emitter - .image_generation_completed( - call.call_id, - "A tiny blue square".to_string(), - "cG5n".to_string(), - ) + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }, + )) + .await; + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration( + codex_protocol::items::ImageGenerationItem { + id: call.call_id, + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()), + }, + )) .await; - *self.output_hint.lock().await = output_hint; Ok(Box::new(codex_tools::JsonToolOutput::new( json!({ "ok": true }), ))) @@ -451,10 +482,7 @@ mod tests { #[tokio::test] async fn image_generation_publication_is_finalized_by_core() { - let output_hint = Arc::new(Mutex::new(None)); - let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor { - output_hint: Arc::clone(&output_hint), - })); + let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor)); let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; let expected_path = crate::stream_events_utils::image_generation_artifact_path( &turn.config.codex_home, @@ -521,17 +549,5 @@ mod tests { std::fs::read(&expected_path).expect("generated artifact should be saved"), b"png" ); - assert_eq!( - *output_hint.lock().await, - Some(format!( - "Generated images are saved to {} as {} by default.\n\ - If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - expected_path - .parent() - .expect("generated image path should have a parent") - .display(), - expected_path.display(), - )) - ); } } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index b2cbe62907..d6963f0761 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -250,7 +250,8 @@ pub(super) fn implicit_granted_permissions( pub(super) async fn apply_granted_turn_permissions( session: &Session, - cwd: &std::path::Path, + environment_id: &str, + cwd: &Path, sandbox_permissions: SandboxPermissions, additional_permissions: Option, ) -> EffectiveAdditionalPermissions { @@ -262,8 +263,8 @@ pub(super) async fn apply_granted_turn_permissions( }; } - let granted_session_permissions = session.granted_session_permissions().await; - let granted_turn_permissions = session.granted_turn_permissions().await; + let granted_session_permissions = session.granted_session_permissions(environment_id).await; + let granted_turn_permissions = session.granted_turn_permissions(environment_id).await; let granted_permissions = merge_permission_profiles( granted_session_permissions.as_ref(), granted_turn_permissions.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 70a31fe0e0..a20ff2b665 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -6,16 +6,25 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::parse_arguments; use crate::tools::handlers::parse_arguments_with_base_path; +use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; +use serde::Deserialize; pub struct RequestPermissionsHandler; +#[derive(Deserialize)] +struct RequestPermissionsEnvironmentArgs { + #[serde(default, rename = "environment_id", alias = "environmentId")] + environment_id: Option, +} + #[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { fn tool_name(&self) -> ToolName { @@ -48,9 +57,16 @@ impl ToolExecutor for RequestPermissionsHandler { } }; - #[allow(deprecated)] + let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?; + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? + else { + return Err(FunctionCallError::RespondToModel( + "request_permissions requires a primary environment".to_string(), + )); + }; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, &turn.cwd)?; + parse_arguments_with_base_path(&arguments, &turn_environment.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; @@ -61,7 +77,13 @@ impl ToolExecutor for RequestPermissionsHandler { } let response = session - .request_permissions(&turn, call_id, args, cancellation_token) + .request_permissions_for_environment( + &turn, + call_id, + args, + turn_environment.selection(), + cancellation_token, + ) .await .ok_or_else(|| { FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index c890e0d8da..504653cf76 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -84,10 +84,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result ToolSpec { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); @@ -247,7 +254,7 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { } pub fn request_permissions_tool_description() -> String { - "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." .to_string() } diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index b7c0db7242..b7bbfa54a1 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -172,6 +172,13 @@ fn request_permissions_tool_includes_full_permission_schema() { "Optional short explanation for why additional permissions are needed.".to_string(), )), ), + ( + "environment_id".to_string(), + JsonSchema::string(Some( + "Environment id from . Omit to use the primary environment." + .to_string(), + )), + ), ("permissions".to_string(), permission_profile_schema()), ]); diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index c26fa107c7..ba69f0d039 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -173,6 +173,7 @@ impl ToolExecutor for ExecCommandHandler { let requested_additional_permissions = additional_permissions.clone(); let effective_additional_permissions = apply_granted_turn_permissions( context.session.as_ref(), + &turn_environment.environment_id, cwd.as_path(), sandbox_permissions, additional_permissions, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9d5aa8f8ee..48111fb578 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry( specs.push(spec_for_model_request(turn_context, exposure, spec)); } } - for spec in hosted_specs { - if !is_hidden_by_code_mode_only( - turn_context, - &ToolName::plain(spec.name()), - ToolExposure::Direct, - ) { - specs.push(spec); - } - } + specs.extend(hosted_specs); let registry = ToolRegistry::from_tools(runtimes); let model_visible_specs = merge_into_namespaces(specs) @@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { }) { specs.push(web_search_tool); } + // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) && !standalone_image_generation_available(turn_context, context.extension_tool_executors) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 1cb36e474d..02a7a21f63 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { }) .await; - assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]); + assert_eq!( + plan.visible_names, + vec![ + "exec", + "wait", + "agents", + // Hosted Responses tools. + "web_search", + ] + ); assert!( !plan .namespace_function_names("agents") @@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { } ); + let code_mode_only = probe(|turn| { + use_chatgpt_auth(turn); + set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]); + set_web_search_mode(turn, WebSearchMode::Live); + turn.model_info.input_modalities = vec![InputModality::Image]; + }) + .await; + assert_eq!( + code_mode_only.visible_names, + vec![ + // Code-mode entrypoints. + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + // Multi-agent v2 tools. + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "close_agent", + "list_agents", + // Hosted Responses tools. + "web_search", + "image_generation", + ] + ); + let standalone_web_search_without_web_run = probe(|turn| { set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); set_web_search_mode(turn, WebSearchMode::Live); diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 7296d6d3f0..e66a1fbb50 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -33,6 +33,7 @@ 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"; +const WORKSPACE_KIND_KEY: &str = "workspace_kind"; const REQUEST_KIND_KEY: &str = "request_kind"; const COMPACTION_KEY: &str = "compaction"; const WINDOW_ID_KEY: &str = "window_id"; @@ -443,6 +444,14 @@ impl TurnMetadataState { Some(responsesapi_client_metadata); } + pub(crate) fn workspace_kind(&self) -> Option { + self.responsesapi_client_metadata + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|metadata| metadata.get(WORKSPACE_KIND_KEY).cloned()) + } + pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) { *self .turn_started_at_unix_ms diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index ffbeb83d26..32a559848a 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -546,6 +546,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("workspace_kind".to_string(), "projectless".to_string()), ("model".to_string(), "client-supplied".to_string()), ( "reasoning_effort".to_string(), @@ -580,6 +581,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["workspace_kind"].as_str(), Some("projectless")); assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); @@ -619,6 +621,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert!(meta.get(WINDOW_ID_KEY).is_none()); + assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); } #[test] diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 3d7fd21228..be07b803f8 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -8,10 +8,9 @@ use ctor::ctor; use std::sync::OnceLock; use tempfile::TempDir; -use codex_config::CloudRequirementsLoader; -use codex_config::ConfigRequirementsToml; +use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; -use codex_config::NetworkRequirementsToml; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::CodexThread; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -169,40 +168,37 @@ pub fn fetch_dotslash_file( /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config { - load_default_config_for_test_with_cloud_requirements( + load_default_config_for_test_with_cloud_config_bundle( codex_home, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), ) .await } -/// Returns a default `Config` with test-provided cloud requirements applied +/// Returns a default `Config` with test-provided cloud bundle requirements applied. /// during config construction. -pub async fn load_default_config_for_test_with_cloud_requirements( +pub async fn load_default_config_for_test_with_cloud_config_bundle( codex_home: &TempDir, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, ) -> Config { ConfigBuilder::default() .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(default_test_overrides()) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .build() .await .expect("defaults for test should always succeed") } -pub fn managed_network_requirements_loader() -> CloudRequirementsLoader { - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - network: Some(NetworkRequirementsToml { - enabled: Some(true), - allow_local_binding: Some(true), - ..Default::default() - }), - ..Default::default() - })) - }) +pub fn managed_network_requirements_loader() -> CloudConfigBundleLoader { + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[experimental_network] +enabled = true +allow_local_binding = true +"#, + ) } #[cfg(target_os = "linux")] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 73e9e0ece8..4fe0609a71 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -12,7 +12,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; -use codex_config::CloudRequirementsLoader; +use codex_config::CloudConfigBundleLoader; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::config::Config; @@ -49,7 +49,7 @@ use crate::PathBufExt; use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; -use crate::load_default_config_for_test_with_cloud_requirements; +use crate::load_default_config_for_test_with_cloud_config_bundle; use crate::responses::WebSocketTestServer; use crate::responses::output_value_to_text; use crate::responses::start_mock_server; @@ -213,7 +213,7 @@ pub struct TestCodexBuilder { pre_build_hooks: Vec>, workspace_setups: Vec>, home: Option>, - cloud_requirements: Option, + cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, } @@ -262,8 +262,11 @@ impl TestCodexBuilder { self } - pub fn with_cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { - self.cloud_requirements = Some(cloud_requirements); + pub fn with_cloud_config_bundle( + mut self, + cloud_config_bundle: CloudConfigBundleLoader, + ) -> Self { + self.cloud_config_bundle = Some(cloud_config_bundle); self } @@ -545,8 +548,8 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - let mut config = if let Some(cloud_requirements) = self.cloud_requirements.take() { - load_default_config_for_test_with_cloud_requirements(home, cloud_requirements).await + let mut config = if let Some(cloud_config_bundle) = self.cloud_config_bundle.take() { + load_default_config_for_test_with_cloud_config_bundle(home, cloud_config_bundle).await } else { load_default_config_for_test(home).await }; @@ -1031,7 +1034,7 @@ pub fn test_codex() -> TestCodexBuilder { pre_build_hooks: vec![], workspace_setups: vec![], home: None, - cloud_requirements: None, + cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a91196a0b9..aceac7320e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -2999,7 +2999,7 @@ allow_local_binding = true let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config @@ -3279,7 +3279,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); @@ -3484,7 +3484,7 @@ allow_local_binding = true }; let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); let cwd = config.cwd.clone(); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a535eb4b8e..5813508e7a 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2229,7 +2229,7 @@ allow_local_binding = true panic!("failed to write permission request hook test fixture: {error}"); } }) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { trust_discovered_hooks(config); config.permissions.approval_policy = Constrained::allow_any(approval_policy); diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 06385f6561..c17121b521 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -5,6 +5,7 @@ use codex_login::CodexAuth; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelVisibility; @@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only"); code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly); + code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image]; let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?; assert_eq!( tool_names(&code_mode_only_body), vec![ + // Code-mode entrypoints. codex_code_mode::PUBLIC_TOOL_NAME.to_string(), codex_code_mode::WAIT_TOOL_NAME.to_string(), + // Hosted Responses tools. + "web_search".to_string(), + "image_generation".to_string(), ] ); diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 34cd119f66..0862482f9d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -22,6 +23,9 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; @@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ExecPermissionApprovals) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + + let local_cwd = TempDir::new()?; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-remote-request-permissions-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let relative_write_root = "granted"; + let relative_target_path = "granted/request-permissions-output.txt"; + let remote_write_root = remote_cwd.join(relative_write_root); + let remote_target_path = remote_cwd.join(relative_target_path); + let local_write_root = local_cwd.path().join(relative_write_root); + let local_target_path = local_cwd.path().join(relative_target_path); + fs::create_dir(&local_write_root)?; + test.fs() + .create_directory( + &remote_write_root, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + Some(vec![]), + Some(vec![remote_write_root.clone()]), + )), + ..RequestPermissionProfile::default() + }; + let approved_response = RequestPermissionsResponse { + permissions: expected_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + let command = format!( + "printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}" + ); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-permissions-remote-1"), + ev_function_call( + "permissions-call", + "request_permissions", + &json!({ + "environment_id": REMOTE_ENVIRONMENT_ID, + "reason": "Allow writing inside the selected remote environment", + "permissions": { + "file_system": { + "write": [relative_write_root], + }, + }, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-1"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-2"), + ev_function_call( + "exec-call", + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 1_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-request-permissions-remote-2"), + ]), + sse(vec![ + ev_response_created("resp-request-permissions-remote-3"), + ev_assistant_message("msg-request-permissions-remote-1", "done"), + ev_completed("resp-request-permissions-remote-3"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "request permissions, then write in the remote environment", + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: local_cwd.path().abs(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd.clone(), + }, + ], + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::RequestPermissions(request) = event else { + panic!("expected remote request_permissions before completion: {event:?}"); + }; + assert_eq!(request.call_id, "permissions-call"); + assert_eq!( + request.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!(request.cwd.as_ref(), Some(&remote_cwd)); + assert_eq!(request.permissions, expected_permissions); + + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: approved_response.clone(), + }) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ExecApprovalRequest(approval) => { + panic!("remote request_permissions grant should preapprove exec: {approval:?}"); + } + other => panic!("unexpected event: {other:?}"), + } + + let permissions_output: RequestPermissionsResponse = serde_json::from_str( + &response_mock + .function_call_output_text("permissions-call") + .expect("expected request_permissions output"), + )?; + assert_eq!(permissions_output, approved_response); + let exec_output = response_mock + .function_call_output_text("exec-call") + .expect("expected exec output"); + assert!( + exec_output.contains("remote-request-permissions-ok"), + "unexpected exec output: {exec_output}", + ); + assert_eq!( + test.fs() + .read_file_text(&remote_target_path, /*sandbox*/ None) + .await?, + "remote-request-permissions-ok" + ); + assert!( + !local_target_path.exists(), + "remote exec should not write through the local environment" + ); + + test.fs() + .remove( + &remote_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index ba754b025f..d86a0f620e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -916,7 +916,7 @@ allow_local_binding = true let permission_profile = permission_profile_for_config.clone(); let mut builder = test_codex() .with_home(home) - .with_cloud_requirements(managed_network_requirements_loader()) + .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.use_experimental_unified_exec_tool = true; config diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index e857f358c9..406197d720 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; @@ -330,53 +331,54 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ..Default::default() }; - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), - ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = loader_overrides.clone(); - let run_cloud_requirements = cloud_requirements.clone(); + let run_cloud_config_bundle = cloud_config_bundle.clone(); let model_provider = if oss { - let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + Some(&config_cwd), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -437,7 +439,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .harness_overrides(overrides) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() }; let config = build_exec_config( @@ -536,7 +538,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, strict_config, - cloud_requirements: run_cloud_requirements, + cloud_config_bundle: run_cloud_config_bundle, feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), @@ -606,6 +608,46 @@ where } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let ExecRunArgs { in_process_start_args, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index d9bebb1b32..8b2f214987 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -578,6 +578,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { sandbox_mode: Some(SandboxMode::DangerFullAccess), diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 2f00a8dd98..4705581c8a 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -13,7 +13,6 @@ pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; -pub use codex_tools::ImageGenerationCompletionFuture; pub use codex_tools::JsonToolOutput; pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index f866a9018b..44f2d80ebe 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -26,7 +26,6 @@ use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; const RESULT: &str = "cG5n"; -const OUTPUT_HINT: &str = "Generated images are saved to /tmp as /tmp/call-1.png by default."; #[test] fn uses_reserved_image_gen_namespace() { @@ -55,10 +54,9 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input_and_output_hint() { +fn generated_output_returns_image_input() { let output = GeneratedImageOutput { result: RESULT.to_string(), - output_hint: Some(OUTPUT_HINT.to_string()), }; let ResponseInputItem::FunctionCallOutput { @@ -73,15 +71,10 @@ fn generated_output_returns_image_input_and_output_hint() { }; assert_eq!( content_items, - vec![ - FunctionCallOutputContentItem::InputImage { - image_url: format!("data:image/png;base64,{RESULT}"), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputText { - text: OUTPUT_HINT.to_string(), - }, - ] + vec![FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }] ); } diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 75a79db2bf..41d407d912 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; use codex_extension_api::ToolExecutor; @@ -11,6 +12,7 @@ use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::models::ContentItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::FunctionCallOutputBody; @@ -84,6 +86,15 @@ impl ToolExecutor for ImageGenerationTool { async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; let request = request_for_action(&args, call.conversation_history.items())?; + call.turn_item_emitter + .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + })) + .await; let response = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, ImageRequest::Edit(request) => self.backend.edit(request).await, @@ -96,14 +107,16 @@ impl ToolExecutor for ImageGenerationTool { "image generation returned no image data".to_string(), )); }; - let output_hint = call - .turn_item_emitter - .image_generation_completed(call.call_id.clone(), args.prompt, result.clone()) + call.turn_item_emitter + .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem { + id: call.call_id.clone(), + status: "completed".to_string(), + revised_prompt: Some(args.prompt), + result: result.clone(), + saved_path: None, + })) .await; - Ok(Box::new(GeneratedImageOutput { - result, - output_hint, - })) + Ok(Box::new(GeneratedImageOutput { result })) } } @@ -284,7 +297,6 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, - output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -298,17 +310,12 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes and persisted-artifact context for model follow-up. + /// Returns generated bytes for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let mut content = vec![FunctionCallOutputContentItem::InputImage { + let content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; - if let Some(output_hint) = &self.output_hint { - content.push(FunctionCallOutputContentItem::InputText { - text: output_hint.clone(), - }); - } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index fd13753ed5..92d17fbabd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -274,9 +274,6 @@ fn fallback_managed_hooks_source_path( Some(RequirementSource::MdmManagedPreferences { domain, key }) => { synthetic_layer_path(&format!("/requirements.toml")) } - Some(RequirementSource::CloudRequirements) => { - synthetic_layer_path("/requirements.toml") - } Some(RequirementSource::Composite { .. }) => { synthetic_layer_path("/requirements.toml") } @@ -635,7 +632,6 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, Some(RequirementSource::Composite { sources }) => { // Requirements hook composition preserves contributing sources in // priority order, but discovery only carries one source for the diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9b642503a0..4b7697b34f 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -120,12 +120,12 @@ fn requirements_with_managed_hooks_only( ConfigRequirements { allow_managed_hooks_only: Some(Sourced::new( allow_managed_hooks_only, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), managed_hooks: managed_hooks.clone().map(|hooks| { ConstrainedWithSource::new( Constrained::allow_any(hooks), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ) }), ..ConfigRequirements::default() @@ -183,7 +183,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -208,7 +208,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, @@ -286,7 +289,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -447,7 +450,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -471,7 +474,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); + assert_eq!( + engine.handlers[0].source, + HookSource::LegacyManagedConfigMdm + ); let discovered = super::discovery::discover_handlers( Some(&config_layer_stack), Vec::new(), @@ -671,7 +677,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { ConfigRequirements { managed_hooks: Some(ConstrainedWithSource::new( Constrained::allow_any(managed_hooks.clone()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }, @@ -1237,7 +1243,12 @@ print(json.dumps({ assert_eq!(outcome.hook_events.len(), 1); assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin); - assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + assert_eq!( + outcome.hook_events[0].run.status, + HookRunStatus::Completed, + "hook entries: {:#?}", + outcome.hook_events[0].run.entries + ); assert_eq!(outcome.hook_events[0].run.entries.len(), 1); assert_eq!( outcome.hook_events[0].run.entries[0].kind, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index cbf8895ddb..5a08610cd6 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -374,16 +374,9 @@ impl Default for PermissionProfile { impl PermissionProfile { /// Managed read-only filesystem access with restricted network access. pub fn read_only() -> Self { + let file_system = FileSystemSandboxPolicy::read_only(); Self::Managed { - file_system: ManagedFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), network: NetworkSandboxPolicy::Restricted, } } diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index f90859a92b..e5fbc5a0bb 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -358,22 +358,26 @@ pub fn project_roots_glob_pattern(subpath: &Path) -> String { format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) } +fn read_only_file_system_entries() -> Vec { + vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }] +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { - Self { - kind: FileSystemSandboxKind::Restricted, - glob_scan_max_depth: None, - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - } + Self::read_only() } } impl FileSystemSandboxPolicy { + pub fn read_only() -> Self { + Self::restricted(read_only_file_system_entries()) + } + pub fn unrestricted() -> Self { Self { kind: FileSystemSandboxKind::Unrestricted, diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index be6b88ef52..1752363ec5 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -48,6 +48,14 @@ impl From for RequestPermissionProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsArgs { + #[serde( + default, + rename = "environment_id", + alias = "environmentId", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + pub environment_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, @@ -71,6 +79,15 @@ pub struct RequestPermissionsEvent { /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] pub turn_id: String, + #[serde( + default, + rename = "environmentId", + alias = "environment_id", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + #[ts(rename = "environmentId")] + pub environment_id: Option, #[ts(type = "number")] pub started_at_ms: i64, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 28c4f93bba..1cee3f7d2d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -64,7 +64,6 @@ pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_call::ConversationHistory; pub use tool_call::ExtensionTurnItem; -pub use tool_call::ImageGenerationCompletionFuture; pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; pub use tool_call::TurnItemEmissionFuture; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 31143f9f1f..e3fc21e864 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -1,6 +1,7 @@ use crate::FunctionCallError; use crate::ToolName; use crate::ToolPayload; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::WebSearchItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::TruncationPolicy; @@ -29,20 +30,14 @@ impl ConversationHistory { /// Future returned when an extension tool emits a visible turn-item lifecycle event. pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; -/// Future returned when an image-generation extension publishes completed image bytes. -pub type ImageGenerationCompletionFuture<'a> = - Pin> + Send + 'a>>; - -/// Visible turn items that an extension fully owns and may emit as-is. -/// -/// Add only item kinds that require no additional host finalization before -/// persistence or client delivery. Richer items need a host-owned publish path. +/// Visible turn items that an extension may publish into the host lifecycle. #[derive(Clone, Debug, PartialEq)] pub enum ExtensionTurnItem { WebSearch(WebSearchItem), + ImageGeneration(ImageGenerationItem), } -/// Host-provided capability for extension tools to emit finalized visible turn items. +/// Host-provided capability for extension tools to emit visible turn items. /// /// Implementations route lifecycle events through the host's normal item event /// pipeline, including any persistence and client delivery owned by the host. @@ -50,21 +45,8 @@ pub trait TurnItemEmitter: Send + Sync { /// Emits the beginning of one visible turn item. fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - /// Emits the completion of one visible turn item. + /// Emits one visible turn item after host-owned finalization. fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; - - /// Publishes image bytes for host persistence and visible completion. - /// - /// Returns persisted-artifact context for the extension's model-facing - /// function output when the host saves the generated image successfully. - fn image_generation_completed<'a>( - &'a self, - _call_id: String, - _prompt: String, - _result: String, - ) -> ImageGenerationCompletionFuture<'a> { - Box::pin(std::future::ready(None)) - } } /// Turn-item emitter used when a caller does not expose visible item emission. diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 1843333023..c2580bd075 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -489,6 +489,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "perm-1".to_string(), + environment_id: None, started_at_ms: 0, cwd: absolute_path(if cfg!(windows) { r"C:\tmp" } else { "/tmp" }), reason: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 43e1179841..3570491d1a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1856,12 +1856,20 @@ impl App { )); } ApprovalRequest::Permissions { + environment_id, permissions, reason, .. } => { let _ = tui.enter_alt_screen(); let mut lines = Vec::new(); + if let Some(environment_id) = environment_id { + lines.push(Line::from(vec![ + "Environment: ".into(), + environment_id.bold(), + ])); + lines.push(Line::from("")); + } if let Some(reason) = reason { lines.push(Line::from(vec!["Reason: ".into(), reason.italic()])); lines.push(Line::from("")); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f33f689acd..eb5e10c50f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2539,6 +2539,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( thread_id: thread_id.to_string(), turn_id: "turn-approval".to_string(), item_id: "call-approval".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: test_absolute_path("/tmp"), reason: Some("Need access to .git".to_string()), @@ -2557,7 +2558,9 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( }; let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { - permissions, .. + environment_id, + permissions, + .. })) = app .interactive_request_for_thread_request(thread_id, &request) .await @@ -2565,6 +2568,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( panic!("expected permissions approval request"); }; + assert_eq!(environment_id.as_deref(), Some("remote")); assert_eq!( permissions, RequestPermissionProfile { diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f2c5969f2f..1bd28d5f65 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -310,6 +310,7 @@ impl App { thread_id, thread_label, call_id: params.item_id.clone(), + environment_id: params.environment_id.clone(), reason: params.reason.clone(), permissions: params.permissions.clone().into(), }), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 17b17f3598..f318dd2103 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -84,6 +84,7 @@ pub(crate) enum ApprovalRequest { thread_id: ThreadId, thread_label: Option, call_id: String, + environment_id: Option, reason: Option, permissions: RequestPermissionProfile, }, @@ -713,6 +714,7 @@ fn build_header(request: &ApprovalRequest) -> Box { } ApprovalRequest::Permissions { thread_label, + environment_id, reason, permissions, .. @@ -725,6 +727,13 @@ fn build_header(request: &ApprovalRequest) -> Box { ])); header.push(Line::from("")); } + if let Some(environment_id) = environment_id { + header.push(Line::from(vec![ + "Environment: ".into(), + environment_id.clone().bold(), + ])); + header.push(Line::from("")); + } if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); @@ -1227,6 +1236,7 @@ mod tests { thread_id: ThreadId::new(), thread_label: None, call_id: "test".to_string(), + environment_id: None, reason: Some("need workspace access".to_string()), permissions: RequestPermissionProfile { network: Some(NetworkPermissions { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 246e16d69c..c9827a4e71 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -864,6 +864,7 @@ fn request_permissions_from_params( RequestPermissionsEvent { turn_id: params.turn_id, call_id: params.item_id, + environment_id: params.environment_id, started_at_ms: params.started_at_ms, reason: params.reason, permissions: params.permissions.into(), diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index d732177ce4..18fc155649 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -281,6 +281,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + environment_id: Some("remote".to_string()), started_at_ms: 0, cwd: cwd.clone(), reason: Some("Select a workspace root".to_string()), @@ -310,6 +311,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { } ); assert_eq!(request.cwd, Some(cwd)); + assert_eq!(request.environment_id.as_deref(), Some("remote")); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index 58bbd7cef0..3001563451 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -441,6 +441,7 @@ impl ChatWidget { thread_id: self.thread_id.unwrap_or_default(), thread_label: None, call_id: ev.call_id, + environment_id: ev.environment_id, reason: ev.reason, permissions: ev.permissions, }; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c9d91eed55..562df95989 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -628,7 +628,7 @@ mod tests { let requirements = ConfigRequirements { approval_policy: ConstrainedWithSource::new( Constrained::allow_any(AskForApproval::OnRequest.to_core()), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), approvals_reviewer: ConstrainedWithSource::new( Constrained::allow_any(ApprovalsReviewer::AutoReview), @@ -653,25 +653,25 @@ mod tests { )), enforce_residency: ConstrainedWithSource::new( Constrained::allow_any(Some(ResidencyRequirement::Us)), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), allow_managed_hooks_only: Some(Sourced::new( /*value*/ true, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), allow_appshots: Some(Sourced::new( /*value*/ false, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), network: Some(Sourced::new( NetworkConstraints { @@ -684,7 +684,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), filesystem: Some(Sourced::new( FilesystemConstraints { @@ -694,7 +694,7 @@ mod tests { file: requirements_file.clone(), }, )), - guardian_policy_config_source: Some(RequirementSource::CloudRequirements), + guardian_policy_config_source: Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ..ConfigRequirements::default() }; @@ -749,9 +749,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_approval_policies: on-request (source: {requirements_source})" + ))); assert!(rendered.contains( "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" )); @@ -764,22 +765,28 @@ mod tests { .as_str(), ) ); - assert!( - rendered.contains( - "allowed_web_search_modes: cached, disabled (source: cloud requirements)" - ) - ); - assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)")); - assert!(rendered.contains("allow_appshots: false (source: cloud requirements)")); - assert!( - rendered.contains("guardian_policy_config: configured (source: cloud requirements)") - ); - assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)")); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: cached, disabled (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_managed_hooks_only: true (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "allow_appshots: false (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "guardian_policy_config: configured (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "features: guardian_approval=true (source: {requirements_source})" + ))); assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))")); - assert!(rendered.contains("enforce_residency: us (source: cloud requirements)")); - assert!(rendered.contains( - "experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)" - )); + assert!(rendered.contains(&format!( + "enforce_residency: us (source: {requirements_source})" + ))); + assert!(rendered.contains(&format!( + "experimental_network: enabled=true, domains={{example.com=allow}} (source: {requirements_source})" + ))); assert!( rendered.contains( format!( @@ -834,7 +841,7 @@ mod tests { }), ..Default::default() }, - RequirementSource::CloudRequirements, + RequirementSource::LegacyManagedConfigTomlFromMdm, )), ..ConfigRequirements::default() }; @@ -844,9 +851,10 @@ mod tests { .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!(rendered.contains( - "experimental_network: unix_sockets={/tmp/blocked.sock=deny, /tmp/codex.sock=allow} (source: cloud requirements)" - )); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})" + ))); } #[test] @@ -956,7 +964,7 @@ approval_policy = "never" let requirements = ConfigRequirements { web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Disabled), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), ), ..ConfigRequirements::default() }; @@ -988,9 +996,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); - assert!( - rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)") - ); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); + assert!(rendered.contains(&format!( + "allowed_web_search_modes: disabled (source: {requirements_source})" + ))); } #[test] @@ -1018,7 +1027,7 @@ approval_policy = "never" ..Default::default() }, }), - Some(RequirementSource::CloudRequirements), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm), )), ..ConfigRequirements::default() }; @@ -1033,9 +1042,10 @@ approval_policy = "never" .expect("config layer stack"); let rendered = render_to_text(&render_debug_config_lines(&stack)); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains("hooks:")); assert!(rendered.contains("handlers=1")); - assert!(rendered.contains("(source: cloud requirements)")); + assert!(rendered.contains(&format!("(source: {requirements_source})"))); } #[test] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e9cc88dca1..b53ec0f83d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -38,8 +38,8 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_requirements_loader_for_storage; -use codex_config::CloudRequirementsLoader; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; @@ -298,7 +298,7 @@ async fn start_embedded_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -310,7 +310,7 @@ async fn start_embedded_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -510,7 +510,7 @@ async fn start_app_server( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -523,7 +523,7 @@ async fn start_app_server( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -550,7 +550,7 @@ pub(crate) async fn start_app_server_for_picker( Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -584,7 +584,7 @@ async fn start_embedded_app_server_with( cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, strict_config: bool, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -611,7 +611,7 @@ where cli_overrides: cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -977,51 +977,58 @@ pub async fn run_main( loader_overrides.user_config_profile = Some(profile_v2.clone()); } - #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_and_load_options( + let bootstrap_config_toml = load_config_toml_or_exit( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), - codex_config::ConfigLoadOptions { - loader_overrides: loader_overrides.clone(), - strict_config, - }, + loader_overrides.clone(), + strict_config, + CloudConfigBundleLoader::default(), ) - .await - { - Ok(config_toml) => config_toml, - Err(err) => { - let config_error = err - .get_ref() - .and_then(|err| err.downcast_ref::()) - .map(ConfigLoadError::config_error); - if let Some(config_error) = config_error { - eprintln!( - "Error loading config.toml:\n{}", - format_config_error_with_source(config_error) - ); - } else { - eprintln!("Error loading config.toml: {err}"); - } - std::process::exit(1); - } - }; + .await; - let chatgpt_base_url = config_toml + let chatgpt_base_url = bootstrap_config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), + bootstrap_config_toml + .cli_auth_credentials_store + .unwrap_or_default(), chatgpt_base_url, ) .await; + let cwd_override = if app_server_target.uses_remote_workspace() { + None + } else { + cwd.clone() + }; + let mut manually_selected_oss_provider = None; let model_provider_override = if cli.oss { - let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml); + let config_toml_with_cloud_config; + let config_toml_for_oss = if cli.oss_provider.is_none() { + // The first load intentionally skips cloud config so we can read + // auth/base-url settings needed to fetch the bundle. If OSS mode + // needs a default provider from config, reload with the bundle. + config_toml_with_cloud_config = load_config_toml_or_exit( + &codex_home, + config_cwd.as_ref(), + cli_kv_overrides.clone(), + loader_overrides.clone(), + strict_config, + cloud_config_bundle.clone(), + ) + .await; + &config_toml_with_cloud_config + } else { + &bootstrap_config_toml + }; + + let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss); if let Some(provider) = resolved { Some(provider) @@ -1062,11 +1069,7 @@ pub async fn run_main( model, approval_policy, sandbox_mode, - cwd: if app_server_target.uses_remote_workspace() { - None - } else { - cwd - }, + cwd: cwd_override, model_provider: model_provider_override.clone(), codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), @@ -1081,7 +1084,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1137,7 +1140,7 @@ pub async fn run_main( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await; @@ -1156,6 +1159,11 @@ pub async fn run_main( tracing::warn!(error = %err, "failed to deserialize config for personality migration"); } } + let config_toml_log_dir_configured = config + .config_layer_stack + .effective_config() + .as_table() + .is_some_and(|table| table.contains_key("log_dir")); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await { @@ -1199,7 +1207,7 @@ pub async fn run_main( } } - let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() { + let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured { let log_dir = config.log_dir.clone(); std::fs::create_dir_all(&log_dir)?; let mut log_file_opts = OpenOptions::new(); @@ -1281,7 +1289,7 @@ pub async fn run_main( manually_selected_oss_provider, overrides, cli_kv_overrides, - cloud_requirements, + cloud_config_bundle, feedback, log_db, state_db, @@ -1303,7 +1311,7 @@ async fn run_ratatui_app( manually_selected_oss_provider: Option, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, - mut cloud_requirements: CloudRequirementsLoader, + mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, state_db: Option, @@ -1365,7 +1373,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1448,11 +1456,11 @@ async fn run_ratatui_app( { trust_decision_was_made = onboarding_result.directory_trust_persisted; } - // If this onboarding run included the login step, always refresh cloud requirements and - // rebuild config. This avoids missing newly available cloud requirements due to login + // If this onboarding run included the login step, always refresh the cloud config bundle + // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. if show_login_screen && !uses_remote_workspace { - cloud_requirements = cloud_requirements_loader_for_storage( + cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, @@ -1470,7 +1478,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1674,7 +1682,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, fallback_cwd, ) @@ -1685,7 +1693,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), overrides.clone(), loader_overrides.clone(), - cloud_requirements.clone(), + cloud_config_bundle.clone(), strict_config, ) .await @@ -1745,7 +1753,7 @@ async fn run_ratatui_app( cli_kv_overrides.clone(), loader_overrides.clone(), strict_config, - cloud_requirements.clone(), + cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), state_db.clone(), @@ -1902,14 +1910,14 @@ async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, overrides, loader_overrides, - cloud_requirements, + cloud_config_bundle, strict_config, /*fallback_cwd*/ None, ) @@ -1920,7 +1928,7 @@ async fn load_config_or_exit_with_fallback_cwd( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, + cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, fallback_cwd: Option, ) -> Config { @@ -1930,7 +1938,7 @@ async fn load_config_or_exit_with_fallback_cwd( .harness_overrides(overrides) .loader_overrides(loader_overrides) .strict_config(strict_config) - .cloud_requirements(cloud_requirements) + .cloud_config_bundle(cloud_config_bundle) .fallback_cwd(fallback_cwd) .build() .await @@ -1943,6 +1951,46 @@ async fn load_config_or_exit_with_fallback_cwd( } } +#[allow(clippy::print_stderr)] +async fn load_config_toml_or_exit( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_kv_overrides: Vec<(String, codex_config::TomlValue)>, + loader_overrides: LoaderOverrides, + strict_config: bool, + cloud_config_bundle: CloudConfigBundleLoader, +) -> codex_config::config_toml::ConfigToml { + match load_config_as_toml_with_cli_and_load_options( + codex_home, + cwd, + cli_kv_overrides, + codex_config::ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle, + }, + ) + .await + { + Ok(config_toml) => config_toml, + Err(err) => { + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } + std::process::exit(1); + } + } +} + /// Determine if the user has decided whether to trust the current directory. fn should_show_trust_screen(config: &Config) -> bool { config.active_project.trust_level.is_none() @@ -2017,7 +2065,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, @@ -2771,7 +2819,7 @@ mod tests { Vec::new(), LoaderOverrides::default(), /*strict_config*/ false, - CloudRequirementsLoader::default(), + CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, /*state_db*/ None, diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index fa3561a7fa..0d8a1fa773 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1012,7 +1012,7 @@ mod tests { use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; - use codex_cloud_config::cloud_requirements_loader_for_storage; + use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; @@ -1033,7 +1033,7 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, - cloud_requirements: cloud_requirements_loader_for_storage( + cloud_config_bundle: cloud_config_bundle_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 40d33eb2f6..4e5c420f63 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -16,7 +16,8 @@ use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_requirements_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadOptions; use codex_config::LoaderOverrides; use codex_exec_server::EnvironmentManager; @@ -251,6 +252,7 @@ async fn start_app_server_for_archive_command( ConfigLoadOptions { loader_overrides: loader_overrides.clone(), strict_config, + cloud_config_bundle: CloudConfigBundleLoader::default(), }, ) .await @@ -259,7 +261,7 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader_for_storage( + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), @@ -298,7 +300,7 @@ async fn start_app_server_for_archive_command( }) .loader_overrides(loader_overrides.clone()) .strict_config(strict_config) - .cloud_requirements(cloud_requirements.clone()) + .cloud_config_bundle(cloud_config_bundle.clone()) .build() .await .wrap_err("failed to load configuration")?; @@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command( cli_kv_overrides, loader_overrides, strict_config, - cloud_requirements, + cloud_config_bundle, codex_feedback::CodexFeedback::new(), /*log_db*/ None, state_db, diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9c93be69a5..6b7bbddd87 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.132.0"] +dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.136.0"] [project.urls] Homepage = "https://github.com/openai/codex" @@ -78,10 +78,10 @@ combine-as-imports = true [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" [tool.uv.pip] exclude-newer = "7 days" -exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" } +exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" } index-strategy = "first-index" diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 1742ced547..a4af5d605e 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -1163,7 +1163,9 @@ def generate_public_api_flat_methods() -> None: turn_start_fields = _load_public_fields( "openai_codex.generated.v2_all", "TurnStartParams", - exclude={"thread_id", "input", *approval_fields}, + # Keep the wire model current without exposing this app-server field + # through the ergonomic Python API yet. + exclude={"thread_id", "input", "client_user_message_id", *approval_fields}, ) turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy") @@ -1267,7 +1269,7 @@ def build_parser() -> argparse.ArgumentParser: "--platform-tag", help=( "Optional wheel platform tag override, for example " - "macosx_11_0_arm64 or musllinux_1_1_x86_64." + "macosx_11_0_arm64 or manylinux_2_17_x86_64." ), ) return parser diff --git a/sdk/python/src/openai_codex/generated/notification_registry.py b/sdk/python/src/openai_codex/generated/notification_registry.py index c55eb5b9b7..d5e620a7a3 100644 --- a/sdk/python/src/openai_codex/generated/notification_registry.py +++ b/sdk/python/src/openai_codex/generated/notification_registry.py @@ -57,6 +57,7 @@ from .v2_all import ThreadRealtimeSdpNotification from .v2_all import ThreadRealtimeStartedNotification from .v2_all import ThreadRealtimeTranscriptDeltaNotification from .v2_all import ThreadRealtimeTranscriptDoneNotification +from .v2_all import ThreadSettingsUpdatedNotification from .v2_all import ThreadStartedNotification from .v2_all import ThreadStatusChangedNotification from .v2_all import ThreadTokenUsageUpdatedNotification @@ -122,6 +123,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = { "thread/realtime/started": ThreadRealtimeStartedNotification, "thread/realtime/transcript/delta": ThreadRealtimeTranscriptDeltaNotification, "thread/realtime/transcript/done": ThreadRealtimeTranscriptDoneNotification, + "thread/settings/updated": ThreadSettingsUpdatedNotification, "thread/started": ThreadStartedNotification, "thread/status/changed": ThreadStatusChangedNotification, "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 85120b8293..c27a8f75ca 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -56,7 +56,7 @@ class ActivePermissionProfile(BaseModel): extends: Annotated[ str | None, Field( - description="Parent profile identifier once permissions profiles support inheritance. This is currently always `null`." + description="Parent profile identifier from the selected permissions profile's `extends` setting, when present." ), ] = None id: Annotated[ @@ -77,6 +77,11 @@ class AddCreditsNudgeEmailStatus(Enum): cooldown_active = "cooldown_active" +class AdditionalContextKind(Enum): + untrusted = "untrusted" + application = "application" + + class AdditionalNetworkPermissions(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -251,6 +256,11 @@ class AuthMode(Enum): agent_identity = "agentIdentity" +class AutoCompactTokenLimitScope(Enum): + total = "total" + body_after_prefix = "body_after_prefix" + + class AutoReviewDecisionSource(RootModel[Literal["agent"]]): model_config = ConfigDict( populate_by_name=True, @@ -563,6 +573,13 @@ class CommandMigration(BaseModel): name: str +class ComputerUseRequirements(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + allow_locked_computer_use: Annotated[bool | None, Field(alias="allowLockedComputerUse")] = None + + class MdmConfigLayerSource(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -675,7 +692,7 @@ class ConfigReadParams(BaseModel): description="Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root)." ), ] = None - include_layers: Annotated[bool | None, Field(alias="includeLayers")] = False + include_layers: Annotated[bool | None, Field(alias="includeLayers")] = None class CommandConfiguredHookHandler(BaseModel): @@ -913,7 +930,7 @@ class FeedbackUploadParams(BaseModel): ) classification: str extra_log_files: Annotated[list[str] | None, Field(alias="extraLogFiles")] = None - include_logs: Annotated[bool, Field(alias="includeLogs")] + include_logs: Annotated[bool | None, Field(alias="includeLogs")] = None reason: str | None = None tags: dict[str, Any] | None = None thread_id: Annotated[str | None, Field(alias="threadId")] = None @@ -939,7 +956,7 @@ class FileChangeOutputDeltaNotification(BaseModel): class FileSystemAccessMode(Enum): read = "read" write = "write" - none = "none" + deny = "deny" class PathFileSystemPath(BaseModel): @@ -1276,6 +1293,17 @@ class InputTextFunctionCallOutputContentItem(BaseModel): ] +class EncryptedContentFunctionCallOutputContentItem(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + encrypted_content: str + type: Annotated[ + Literal["encrypted_content"], + Field(title="EncryptedContentFunctionCallOutputContentItemType"), + ] + + class FuzzyFileSearchMatchType(Enum): file = "file" directory = "directory" @@ -1335,7 +1363,7 @@ class GetAccountParams(BaseModel): alias="refreshToken", description="When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", ), - ] = False + ] = None class GitInfo(BaseModel): @@ -1425,6 +1453,8 @@ class HookEventName(Enum): post_compact = "postCompact" session_start = "sessionStart" user_prompt_submit = "userPromptSubmit" + subagent_start = "subagentStart" + subagent_stop = "subagentStop" stop = "stop" @@ -1506,6 +1536,8 @@ class HooksListParams(BaseModel): class ImageDetail(Enum): + auto = "auto" + low = "low" high = "high" original = "original" @@ -1742,6 +1774,8 @@ class ManagedHooksRequirements(BaseModel): pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")] session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")] stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="Stop")] + subagent_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStart")] + subagent_stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStop")] user_prompt_submit: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="UserPromptSubmit")] managed_dir: Annotated[str | None, Field(alias="managedDir")] = None windows_managed_dir: Annotated[str | None, Field(alias="windowsManagedDir")] = None @@ -1835,6 +1869,18 @@ class McpResourceReadParams(BaseModel): uri: str +class McpServerInfo(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + icons: list | None = None + name: str + title: str | None = None + version: str + website_url: Annotated[str | None, Field(alias="websiteUrl")] = None + + class McpServerMigration(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -2142,7 +2188,7 @@ class NetworkRequirements(BaseModel): class NetworkUnixSocketPermission(Enum): allow = "allow" - none = "none" + deny = "deny" class NonSteerableTurnKind(Enum): @@ -2188,6 +2234,32 @@ class PatchChangeKind( root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind +class PermissionProfileListParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cursor: Annotated[ + str | None, Field(description="Opaque pagination cursor returned by a previous call.") + ] = None + cwd: Annotated[ + str | None, + Field(description="Optional working directory to resolve project config layers."), + ] = None + limit: Annotated[ + int | None, Field(description="Optional page size; defaults to the full result set.", ge=0) + ] = None + + +class PermissionProfileSummary(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + description: Annotated[ + str | None, Field(description="Optional user-facing description for display in clients.") + ] = None + id: Annotated[str, Field(description="Available permission profile identifier.")] + + class Personality(Enum): none = "none" friendly = "friendly" @@ -2332,6 +2404,7 @@ class PluginInterface(BaseModel): class PluginListMarketplaceKind(Enum): local = "local" + vertical = "vertical" workspace_directory = "workspace-directory" shared_with_me = "shared-with-me" @@ -3469,6 +3542,20 @@ class SkillsConfigWriteResponse(BaseModel): effective_enabled: Annotated[bool, Field(alias="effectiveEnabled")] +class SkillsExtraRootsSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + extra_roots: Annotated[list[AbsolutePathBuf], Field(alias="extraRoots")] + + +class SkillsExtraRootsSetResponse(BaseModel): + pass + model_config = ConfigDict( + populate_by_name=True, + ) + + class SkillsListParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3624,6 +3711,20 @@ class ThreadCompactStartResponse(BaseModel): ) +class ThreadGoalClearParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + +class ThreadGoalClearResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + cleared: bool + + class ThreadGoalClearedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -3631,6 +3732,13 @@ class ThreadGoalClearedNotification(BaseModel): thread_id: Annotated[str, Field(alias="threadId")] +class ThreadGoalGetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + + class ThreadGoalStatus(Enum): active = "active" paused = "paused" @@ -3749,6 +3857,7 @@ class McpToolCallThreadItem(BaseModel): error: McpToolCallError | None = None id: str mcp_app_resource_uri: Annotated[str | None, Field(alias="mcpAppResourceUri")] = None + plugin_id: Annotated[str | None, Field(alias="pluginId")] = None result: McpToolCallResult | None = None server: str status: McpToolCallStatus @@ -3922,7 +4031,7 @@ class ThreadReadParams(BaseModel): alias="includeTurns", description="When true, include turns and their items from rollout history.", ), - ] = False + ] = None thread_id: Annotated[str, Field(alias="threadId")] @@ -4536,6 +4645,14 @@ class AccountUpdatedNotification(BaseModel): plan_type: Annotated[PlanType | None, Field(alias="planType")] = None +class AdditionalContextEntry(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + kind: AdditionalContextKind + value: str + + class AppConfig(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4629,6 +4746,24 @@ class ThreadNameSetRequest(BaseModel): params: ThreadSetNameParams +class ThreadGoalGetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/get"], Field(title="Thread/goal/getRequestMethod")] + params: ThreadGoalGetParams + + +class ThreadGoalClearRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/clear"], Field(title="Thread/goal/clearRequestMethod")] + params: ThreadGoalClearParams + + class ThreadMetadataUpdateRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4730,6 +4865,17 @@ class SkillsListRequest(BaseModel): params: SkillsListParams +class SkillsExtraRootsSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["skills/extraRoots/set"], Field(title="Skills/extraRoots/setRequestMethod") + ] + params: SkillsExtraRootsSetParams + + class HooksListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -4995,6 +5141,17 @@ class ExperimentalFeatureListRequest(BaseModel): params: ExperimentalFeatureListParams +class PermissionProfileListRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[ + Literal["permissionProfile/list"], Field(title="PermissionProfile/listRequestMethod") + ] + params: PermissionProfileListParams + + class ExperimentalFeatureEnablementSetRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -5431,16 +5588,22 @@ class ConfigRequirements(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + allow_appshots: Annotated[bool | None, Field(alias="allowAppshots")] = None allow_managed_hooks_only: Annotated[bool | None, Field(alias="allowManagedHooksOnly")] = None allowed_approval_policies: Annotated[ list[AskForApproval] | None, Field(alias="allowedApprovalPolicies") ] = None + allowed_permissions: Annotated[list[str] | None, Field(alias="allowedPermissions")] = None allowed_sandbox_modes: Annotated[ list[SandboxMode] | None, Field(alias="allowedSandboxModes") ] = None allowed_web_search_modes: Annotated[ list[WebSearchMode] | None, Field(alias="allowedWebSearchModes") ] = None + allowed_windows_sandbox_implementations: Annotated[ + list[WindowsSandboxSetupMode] | None, Field(alias="allowedWindowsSandboxImplementations") + ] = None + computer_use: Annotated[ComputerUseRequirements | None, Field(alias="computerUse")] = None enforce_residency: Annotated[ResidencyRequirement | None, Field(alias="enforceResidency")] = ( None ) @@ -5608,13 +5771,19 @@ class InputImageFunctionCallOutputContentItem(BaseModel): class FunctionCallOutputContentItem( - RootModel[InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem] + RootModel[ + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem + ] ): model_config = ConfigDict( populate_by_name=True, ) root: Annotated[ - InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem, + InputTextFunctionCallOutputContentItem + | InputImageFunctionCallOutputContentItem + | EncryptedContentFunctionCallOutputContentItem, Field( description="Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs." ), @@ -5767,6 +5936,7 @@ class ListMcpServerStatusParams(BaseModel): int | None, Field(description="Optional page size; defaults to a server-defined value.", ge=0), ] = None + thread_id: Annotated[str | None, Field(alias="threadId")] = None class McpResourceReadResponse(BaseModel): @@ -5784,6 +5954,7 @@ class McpServerStatus(BaseModel): name: str resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")] resources: list[Resource] + server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None tools: dict[str, Tool] @@ -5817,6 +5988,13 @@ class Model(BaseModel): ] = [] availability_nux: Annotated[ModelAvailabilityNux | None, Field(alias="availabilityNux")] = None default_reasoning_effort: Annotated[ReasoningEffort, Field(alias="defaultReasoningEffort")] + default_service_tier: Annotated[ + str | None, + Field( + alias="defaultServiceTier", + description="Catalog default service tier id for this model, when one is configured.", + ), + ] = None description: str display_name: Annotated[str, Field(alias="displayName")] hidden: bool @@ -5859,6 +6037,20 @@ class OverriddenMetadata(BaseModel): overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")] +class PermissionProfileListResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + data: list[PermissionProfileSummary] + next_cursor: Annotated[ + str | None, + Field( + alias="nextCursor", + description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + ), + ] = None + + class PluginSharePrincipal(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6332,6 +6524,30 @@ class ThreadGoal(BaseModel): updated_at: Annotated[int, Field(alias="updatedAt")] +class ThreadGoalGetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal | None = None + + +class ThreadGoalSetParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + objective: str | None = None + status: ThreadGoalStatus | None = None + thread_id: Annotated[str, Field(alias="threadId")] + token_budget: Annotated[int | None, Field(alias="tokenBudget")] = None + + +class ThreadGoalSetResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + goal: ThreadGoal + + class ThreadGoalUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6345,6 +6561,7 @@ class UserMessageThreadItem(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_id: Annotated[str | None, Field(alias="clientId")] = None content: list[UserInput] id: str type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")] @@ -6536,6 +6753,55 @@ class ThreadListParams(BaseModel): ] = None +class ThreadResumeInitialTurnsPageParams(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + items_view: Annotated[ + TurnItemsView | None, + Field( + alias="itemsView", + description="How much item detail to include for each returned turn; defaults to summary.", + ), + ] = None + limit: Annotated[int | None, Field(description="Optional turn page size.", ge=0)] = None + sort_direction: Annotated[ + SortDirection | None, + Field( + alias="sortDirection", + description="Optional turn pagination direction; defaults to descending.", + ), + ] = None + + +class ThreadSettings(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + active_permission_profile: Annotated[ + ActivePermissionProfile | None, Field(alias="activePermissionProfile") + ] = None + approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")] + approvals_reviewer: Annotated[ApprovalsReviewer, Field(alias="approvalsReviewer")] + collaboration_mode: Annotated[CollaborationMode, Field(alias="collaborationMode")] + cwd: AbsolutePathBuf + effort: ReasoningEffort | None = None + model: str + model_provider: Annotated[str, Field(alias="modelProvider")] + personality: Personality | None = None + sandbox_policy: Annotated[SandboxPolicy, Field(alias="sandboxPolicy")] + service_tier: Annotated[str | None, Field(alias="serviceTier")] = None + summary: ReasoningSummary | None = None + + +class ThreadSettingsUpdatedNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + thread_id: Annotated[str, Field(alias="threadId")] + thread_settings: Annotated[ThreadSettings, Field(alias="threadSettings")] + + class ThreadStartParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6648,6 +6914,7 @@ class TurnStartParams(BaseModel): description="Override where approval requests are routed for review on this turn and subsequent turns.", ), ] = None + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None cwd: Annotated[ str | None, Field(description="Override the working directory for this turn and subsequent turns."), @@ -6696,6 +6963,7 @@ class TurnSteerParams(BaseModel): model_config = ConfigDict( populate_by_name=True, ) + client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None expected_turn_id: Annotated[ str, Field( @@ -6803,6 +7071,15 @@ class ThreadForkRequest(BaseModel): params: ThreadForkParams +class ThreadGoalSetRequest(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + id: RequestId + method: Annotated[Literal["thread/goal/set"], Field(title="Thread/goal/setRequestMethod")] + params: ThreadGoalSetParams + + class ThreadListRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6891,6 +7168,41 @@ class ConfigValueWriteRequest(BaseModel): params: ConfigValueWriteParams +class Config(BaseModel): + model_config = ConfigDict( + extra="allow", + populate_by_name=True, + ) + analytics: AnalyticsConfig | None = None + approval_policy: AskForApproval | None = None + approvals_reviewer: Annotated[ + ApprovalsReviewer | None, + Field( + description="[UNSTABLE] Optional default for where approval requests are routed for review." + ), + ] = None + compact_prompt: str | None = None + desktop: dict[str, Any] | None = None + developer_instructions: str | None = None + forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None + forced_login_method: ForcedLoginMethod | None = None + instructions: str | None = None + model: str | None = None + model_auto_compact_token_limit: int | None = None + model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | None = None + model_context_window: int | None = None + model_provider: str | None = None + model_reasoning_effort: ReasoningEffort | None = None + model_reasoning_summary: ReasoningSummary | None = None + model_verbosity: Verbosity | None = None + review_model: str | None = None + sandbox_mode: SandboxMode | None = None + sandbox_workspace_write: SandboxWorkspaceWrite | None = None + service_tier: str | None = None + tools: ToolsV2 | None = None + web_search: WebSearchMode | None = None + + class ConfigBatchWriteParams(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6913,6 +7225,15 @@ class ConfigBatchWriteParams(BaseModel): ] = None +class ConfigReadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + config: Config + layers: list[ConfigLayer] | None = None + origins: dict[str, ConfigLayerMetadata] + + class ConfigWriteResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7115,29 +7436,6 @@ class PluginSummary(BaseModel): source: PluginSource -class ProfileV2(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - ), - ] = None - chatgpt_base_url: str | None = None - model: str | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - class RequestPermissionProfile(BaseModel): model_config = ConfigDict( extra="forbid", @@ -7229,6 +7527,16 @@ class ThreadGoalUpdatedServerNotification(BaseModel): params: ThreadGoalUpdatedNotification +class ThreadSettingsUpdatedServerNotification(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + method: Annotated[ + Literal["thread/settings/updated"], Field(title="Thread/settings/updatedNotificationMethod") + ] + params: ThreadSettingsUpdatedNotification + + class ThreadTokenUsageUpdatedServerNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7393,6 +7701,15 @@ class TurnStartedNotification(BaseModel): turn: Turn +class TurnsPage(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + backwards_cursor: Annotated[str | None, Field(alias="backwardsCursor")] = None + data: list[Turn] + next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None + + class PluginShareSaveRequest(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7411,51 +7728,6 @@ class ConfigBatchWriteRequest(BaseModel): params: ConfigBatchWriteParams -class Config(BaseModel): - model_config = ConfigDict( - extra="allow", - populate_by_name=True, - ) - analytics: AnalyticsConfig | None = None - approval_policy: AskForApproval | None = None - approvals_reviewer: Annotated[ - ApprovalsReviewer | None, - Field( - description="[UNSTABLE] Optional default for where approval requests are routed for review." - ), - ] = None - compact_prompt: str | None = None - desktop: dict[str, Any] | None = None - developer_instructions: str | None = None - forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None - forced_login_method: ForcedLoginMethod | None = None - instructions: str | None = None - model: str | None = None - model_auto_compact_token_limit: int | None = None - model_context_window: int | None = None - model_provider: str | None = None - model_reasoning_effort: ReasoningEffort | None = None - model_reasoning_summary: ReasoningSummary | None = None - model_verbosity: Verbosity | None = None - profile: str | None = None - profiles: dict[str, ProfileV2] | None = {} - review_model: str | None = None - sandbox_mode: SandboxMode | None = None - sandbox_workspace_write: SandboxWorkspaceWrite | None = None - service_tier: str | None = None - tools: ToolsV2 | None = None - web_search: WebSearchMode | None = None - - -class ConfigReadResponse(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) - config: Config - layers: list[ConfigLayer] | None = None - origins: dict[str, ConfigLayerMetadata] - - class ExternalAgentConfigDetectResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7892,6 +8164,14 @@ class ThreadRollbackResponse(BaseModel): ] +class ThreadSearchResult(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + ) + snippet: str + thread: Thread + + class ThreadStartResponse(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -7960,6 +8240,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -7971,6 +8254,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8004,6 +8288,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8043,6 +8328,9 @@ class ClientRequest( | ThreadArchiveRequest | ThreadUnsubscribeRequest | ThreadNameSetRequest + | ThreadGoalSetRequest + | ThreadGoalGetRequest + | ThreadGoalClearRequest | ThreadMetadataUpdateRequest | ThreadUnarchiveRequest | ThreadCompactStartRequest @@ -8054,6 +8342,7 @@ class ClientRequest( | ThreadReadRequest | ThreadInjectItemsRequest | SkillsListRequest + | SkillsExtraRootsSetRequest | HooksListRequest | MarketplaceAddRequest | MarketplaceRemoveRequest @@ -8087,6 +8376,7 @@ class ClientRequest( | ModelListRequest | ModelProviderCapabilitiesReadRequest | ExperimentalFeatureListRequest + | PermissionProfileListRequest | ExperimentalFeatureEnablementSetRequest | McpServerOauthLoginRequest | ConfigMcpServerReloadRequest @@ -8158,6 +8448,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification @@ -8227,6 +8518,7 @@ class ServerNotification( | ThreadNameUpdatedServerNotification | ThreadGoalUpdatedServerNotification | ThreadGoalClearedServerNotification + | ThreadSettingsUpdatedServerNotification | ThreadTokenUsageUpdatedServerNotification | TurnStartedServerNotification | HookStartedServerNotification diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index a8dda48854..5a8f1275d6 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -265,8 +265,9 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( "AuthMode", "InputModality", "ExperimentalFeatureStage", - "ProcessOutputStream", + "AutoCompactTokenLimitScope", "CommandExecOutputStream", + "ProcessOutputStream", ] @@ -357,10 +358,10 @@ def test_source_sdk_template_pins_published_runtime() -> None: "dependencies": pyproject["project"]["dependencies"], } == { "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -435,7 +436,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None: } == { "package_name": "openai-codex-cli-bin", "sdk_template_version": "0.0.0-dev", - "runtime_pin": "0.132.0", + "runtime_pin": "0.136.0", "normalized_release_version": "0.116.0a1", "release_tag": "rust-v0.116.0-alpha.1", } @@ -594,11 +595,11 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non tmp_path / "runtime-stage", "0.116.0a1", package_archive, - platform_tag="musllinux_1_1_x86_64", + platform_tag="manylinux_2_17_x86_64", ) pyproject = (staged / "pyproject.toml").read_text() - assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject + assert 'platform-tag = "manylinux_2_17_x86_64"' in pyproject def test_stage_runtime_release_rejects_incomplete_package_layout(tmp_path: Path) -> None: @@ -650,7 +651,7 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non "version": "0.1.0b1", "dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } assert ( @@ -687,7 +688,7 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: ) runtime_stage = script.stage_python_runtime_package( tmp_path / "runtime-stage", - "0.132.0", + "0.136.0", package_archive, ) @@ -700,10 +701,10 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None: "sdk_dependencies": sdk_pyproject["project"]["dependencies"], } == { "sdk_version": "0.1.0b1", - "runtime_version": "0.132.0", + "runtime_version": "0.136.0", "sdk_dependencies": [ "pydantic>=2.12", - "openai-codex-cli-bin==0.132.0", + "openai-codex-cli-bin==0.136.0", ], } @@ -762,7 +763,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> "--codex-version", "rust-v0.116.0-alpha.1", "--platform-tag", - "musllinux_1_1_x86_64", + "manylinux_2_17_x86_64", ] ) @@ -793,7 +794,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) -> script.run_command(args, ops) - assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64:codex-package.tar.gz"] + assert calls == ["stage_runtime:0.116.0a1:manylinux_2_17_x86_64:codex-package.tar.gz"] def test_default_runtime_is_resolved_from_installed_runtime_package( diff --git a/sdk/python/tests/test_contract_generation.py b/sdk/python/tests/test_contract_generation.py index 2c95d06a3e..a6d3d2fdc4 100644 --- a/sdk/python/tests/test_contract_generation.py +++ b/sdk/python/tests/test_contract_generation.py @@ -40,7 +40,7 @@ def test_generated_files_are_up_to_date(): # Regenerate contract artifacts via the pinned runtime package, not a local # app-server binary from the checkout or CI environment. - assert importlib.metadata.version("openai-codex-cli-bin") == "0.132.0" + assert importlib.metadata.version("openai-codex-cli-bin") == "0.136.0" env = os.environ.copy() env.pop("CODEX_EXEC_PATH", None) python_bin = str(Path(sys.executable).parent) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 6d9e40e37a..9dabd34d71 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -openai-codex-cli-bin = "2026-05-20T21:00:00Z" +openai-codex-cli-bin = "2026-06-01T20:00:00Z" [[package]] name = "annotated-types" @@ -299,7 +299,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" }, - { name = "openai-codex-cli-bin", specifier = "==0.132.0" }, + { name = "openai-codex-cli-bin", specifier = "==0.136.0" }, { name = "pydantic", specifier = ">=2.12" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.8" }, @@ -308,15 +308,15 @@ provides-extras = ["dev"] [[package]] name = "openai-codex-cli-bin" -version = "0.132.0" +version = "0.136.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a1/b92b7a1b73a83785d2e1dcd0faecd1b7f886a38cf02a30abe1c35f42f0f7/openai_codex_cli_bin-0.132.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1c22b51dbd679413f84f00b9d8fd4e5cf8a1c0d1c7cc8c42bcb3f9f1b33e2334", size = 89403211, upload-time = "2026-05-20T02:37:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/5f/68/163272e582de55a7f460e2329281267908d75d0fbcbbbb2c6749a6329e6b/openai_codex_cli_bin-0.132.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56217495e6635c8a5d96df820cc0da5f46cd9b6ec6f3a5f67f1607d69ef74256", size = 79058685, upload-time = "2026-05-20T02:37:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/0b/18/a60c6b137e7cd3959cae16ba757f57ca5702979b0ea107a21f516ba15d98/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:09642e7578a3078bfccc82af4077b085d42b0022b529e4b5c645e0a0af3397a4", size = 78689038, upload-time = "2026-05-20T02:37:31.548Z" }, - { url = "https://files.pythonhosted.org/packages/f8/eb/1b184307a67c1006d59b61636bcfcea73a89aa95271f6516ed28dce554ca/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:85aec095f9d144d7a2d1aff39fce77b7240f42014580c35801ba74b9317aa5f7", size = 85528820, upload-time = "2026-05-20T02:37:36.559Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e8/1b823a8bf7b96d1513905ad79b16a146d797f81a19a6bc350a2f95a16661/openai_codex_cli_bin-0.132.0-py3-none-win_amd64.whl", hash = "sha256:3cb5c90c55baa39bd5ddc890d2068d3e1322a57a54d1d0e623819009a205c7f5", size = 86916218, upload-time = "2026-05-20T02:37:41.886Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e6/bb8634bd4f3adaea299c95d7b03105ac417e32dd6d8bc2af5dda141d6f28/openai_codex_cli_bin-0.132.0-py3-none-win_arm64.whl", hash = "sha256:74ef93d3deef7cb83c71d19fc667defe749cdab337ec331f59a23511561b6f6a", size = 79892931, upload-time = "2026-05-20T02:37:46.828Z" }, + { url = "https://files.pythonhosted.org/packages/45/2e/09764338fcb104ebcb1ebf2490d71ab4b8339b5d6f8bdce19480adad5a99/openai_codex_cli_bin-0.136.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9ded24a9977221f67173f49b8c3ba3a944787b7323dbd664dd8e8ae1a5efc0aa", size = 94036262, upload-time = "2026-06-01T19:28:13.291Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/10dea458f8ead97b6449a1c05b65883ffe28724204c05e6169439576ee48/openai_codex_cli_bin-0.136.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d194688c1afe87bf85705528cbcbdb762cb378d8c58c64162402babbd795436", size = 83713829, upload-time = "2026-06-01T19:28:17.39Z" }, + { url = "https://files.pythonhosted.org/packages/7d/83/21402a805f3312196a4f4bef0b6a45d3dee301e3db3ddeee117b4b1e612b/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:bdbdfeeac5bb2e744bd810cb60c55313c280d0f6ff9e93699431bcfc2040c9c2", size = 82981863, upload-time = "2026-06-01T19:28:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/33/79/1617e008381345391def39254404c0248a425739bd13c95d9fbe3e63d16f/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:37b353435f354c6d1bcc32e3b83e40ef9d1ef7294c8adedd229fbaedc82a6f74", size = 90187224, upload-time = "2026-06-01T19:28:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bc/a591d4464f999e0fab6abb5ba369463eee99a82e7c298e2cff257d80b849/openai_codex_cli_bin-0.136.0-py3-none-win_amd64.whl", hash = "sha256:c91ffbb1d903970e74b65958f7d1fa548fac8abdfe9b3a098f3da7f47dbb2575", size = 91137642, upload-time = "2026-06-01T19:28:28.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/f3/e8ccd644e7a87a25229022088abf4d724d3d608fcda7123f9a11515409ca/openai_codex_cli_bin-0.136.0-py3-none-win_arm64.whl", hash = "sha256:9073f7283c47ad3e585e23598ebbef554e85d280cabb637b6cb9a078c5e7086a", size = 83623904, upload-time = "2026-06-01T19:28:32.734Z" }, ] [[package]]