Merge remote-tracking branch 'origin/main' into dev/alexz/connector-guardian-managed-app-requirements

# Conflicts:
#	codex-rs/cloud-config/src/lib.rs
#	codex-rs/core/src/connectors_tests.rs
This commit is contained in:
Alex Zamoshchin
2026-06-02 18:03:23 -04:00
142 changed files with 6156 additions and 4116 deletions
+3 -3
View File
@@ -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
+17 -16
View File
@@ -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
-1
View File
@@ -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' }}
+3 -3
View File
@@ -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
-3
View File
@@ -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",
]
@@ -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]
+7
View File
@@ -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 {
+5
View File
@@ -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<String>,
pub(crate) workspace_kind: Option<String>,
pub(crate) num_input_images: usize,
pub(crate) is_first_turn: bool,
pub(crate) status: Option<TurnStatus>,
pub(crate) turn_error: Option<CodexErrorInfo>,
pub(crate) codex_error_kind: Option<CodexErrKind>,
pub(crate) codex_error_subreason: Option<String>,
pub(crate) codex_error_http_status_code: Option<u16>,
pub(crate) steer_count: Option<usize>,
pub(crate) total_tool_call_count: Option<usize>,
pub(crate) shell_command_count: Option<usize>,
+147
View File
@@ -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<Personality>,
pub workspace_kind: Option<String>,
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<String>,
pub(crate) http_status_code: Option<u16>,
}
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<GuardianReviewEventParams>),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnTokenUsage(Box<TurnTokenUsageFact>),
TurnCodexError(Box<TurnCodexErrorFact>),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
+1
View File
@@ -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;
+41
View File
@@ -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<u64>,
token_usage: Option<TokenUsage>,
completed: Option<CompletedTurnState>,
codex_error: Option<TurnCodexError>,
latest_diff: Option<String>,
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),
+7 -7
View File
@@ -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,
@@ -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"
}
}
+8 -1
View File
@@ -1590,6 +1590,13 @@
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"environmentId": {
"default": null,
"type": [
"null",
"string"
]
},
"itemId": {
"type": "string"
},
@@ -1998,4 +2005,4 @@
}
],
"title": "ServerRequest"
}
}
@@ -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"
}
}
@@ -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.
*/
@@ -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<String>,
/// Unix timestamp (in milliseconds) when this approval request started.
#[ts(type = "number")]
pub started_at_ms: i64,
@@ -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 {
+2 -1
View File
@@ -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": {
@@ -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,
+16 -16
View File
@@ -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<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
cloud_config_bundle: Arc<RwLock<CloudConfigBundleLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
}
@@ -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<dyn ThreadConfigLoader>,
) -> 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<AuthManager>,
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(),
)
}
}
@@ -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
+5 -5
View File
@@ -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<dyn ThreadConfigLoader>,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.cloud_config_bundle,
args.arg0_paths.clone(),
args.thread_config_loader,
);
@@ -772,7 +772,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
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,
+6 -3
View File
@@ -473,11 +473,14 @@ pub async fn run_main_with_transport_options(
.replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader));
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url);
config_manager
.replace_cloud_config_bundle_loader(auth_manager, config.chatgpt_base_url);
}
Err(err) => {
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();
+2 -2
View File
@@ -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(),
);
@@ -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),
);
@@ -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;
@@ -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;
@@ -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::<CloudRequirementsLoadError>() {
if let Some(cloud_error) = source.downcast_ref::<CloudConfigBundleLoadError>() {
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
@@ -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 `{}`",
@@ -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 {
@@ -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,
@@ -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::<ThreadStartResponse>(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::<TurnStartResponse>(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<ItemCompletedNotification> {
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
"#
),
)
}
@@ -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,
@@ -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;
@@ -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,
@@ -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,
@@ -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,
@@ -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::<ThreadResumeResponse>(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,
@@ -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,
@@ -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,
@@ -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");
-18
View File
@@ -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::<TurnAttemptsSiblingTurnsResponse>(&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<ConfigFileResponse, RequestError> {
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::<ConfigFileResponse>(&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
-1
View File
@@ -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;
-1
View File
@@ -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;
+1 -4
View File
@@ -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]
+136
View File
@@ -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<u16> },
}
impl RetryableFailureKind {
pub(crate) fn status_code(self) -> Option<u16> {
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<u16>,
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<Output = Result<CloudConfigBundle, BundleRequestError>> + 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<CloudConfigBundle, BundleRequestError> {
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,
}
}
@@ -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<Option<JoinHandle<()>>> {
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
}
pub fn cloud_config_bundle_loader(
auth_manager: Arc<AuthManager>,
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)
}
+253
View File
@@ -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<Sha256>;
#[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<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
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<String>,
account_id: Option<String>,
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<Utc>,
pub(super) expires_at: DateTime<Utc>,
pub(super) chatgpt_user_id: Option<String>,
pub(super) account_id: Option<String>,
pub(super) bundle: CloudConfigBundle,
}
pub(super) fn cache_payload_bytes(
payload: &CloudConfigBundleCacheSignedPayload,
) -> Option<Vec<u8>> {
serde_json::to_vec(&payload).ok()
}
pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
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;
+206
View File
@@ -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))
);
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -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<u16>,
) {
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<u16>,
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<u16>) -> 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::<Vec<_>>();
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
}
}
+500
View File
@@ -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<String>, Option<String>) {
(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<CloudConfigBundle> {
if bundle.is_empty() {
None
} else {
Some(bundle)
}
}
enum CachedBundleLookup {
Hit(Option<CloudConfigBundle>),
Miss,
}
enum UnauthorizedRecoveryAction {
RetrySameAttempt,
RetryNextAttempt,
}
pub(crate) struct CloudConfigBundleService<C> {
auth_manager: Arc<AuthManager>,
client: Arc<C>,
cache: CloudConfigBundleCache,
codex_home: AbsolutePathBuf,
timeout: Duration,
}
impl<C> Clone for CloudConfigBundleService<C> {
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<C> CloudConfigBundleService<C>
where
C: BundleClient + 'static,
{
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
client: Arc<C>,
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<Option<CloudConfigBundle>, 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<Option<CloudConfigBundle>, 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<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
let mut attempt = 1;
let mut last_status_code: Option<u16> = 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<Option<CloudConfigBundle>, 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<u16>,
message: &str,
) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
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;
File diff suppressed because it is too large Load Diff
+34
View File
@@ -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(())
}
+214
View File
@@ -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<CloudConfigFragment>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct CloudRequirementsTomlBundle {
pub enterprise_managed: Vec<CloudRequirementsFragment>,
}
#[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<ConfigLayerEntry>,
/// Enterprise-managed requirements layers in requirements layer merge order.
pub enterprise_managed_requirements: Vec<RequirementsLayerEntry>,
}
impl CloudConfigBundleLayers {
pub fn from_bundle(
bundle: CloudConfigBundle,
base_dir: &AbsolutePathBuf,
) -> Result<Self, CloudConfigLayerError> {
Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ false)
}
pub fn from_bundle_strict_config(
bundle: CloudConfigBundle,
base_dir: &AbsolutePathBuf,
) -> Result<Self, CloudConfigLayerError> {
Self::from_bundle_impl(bundle, base_dir, /*strict_config*/ true)
}
fn from_bundle_impl(
bundle: CloudConfigBundle,
base_dir: &AbsolutePathBuf,
strict_config: bool,
) -> Result<Self, CloudConfigLayerError> {
// 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::<Vec<_>>();
// 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<u16>,
}
impl CloudConfigBundleLoadError {
pub fn new(
code: CloudConfigBundleLoadErrorCode,
status_code: Option<u16>,
message: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
status_code,
}
}
pub fn code(&self) -> CloudConfigBundleLoadErrorCode {
self.code
}
pub fn status_code(&self) -> Option<u16> {
self.status_code
}
}
#[derive(Clone)]
pub struct CloudConfigBundleLoader {
fut: Shared<BoxFuture<'static, Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>>,
}
impl CloudConfigBundleLoader {
pub fn new<F>(fut: F) -> Self
where
F: Future<Output = Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>>
+ Send
+ 'static,
{
Self {
fut: fut.boxed().shared(),
}
}
pub async fn get(&self) -> Result<Option<CloudConfigBundle>, 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;
@@ -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<_>>(),
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(),
}
);
}
+44 -1
View File
@@ -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<Item = CloudConfigFragment>,
base_dir: &AbsolutePathBuf,
) -> Result<Vec<ConfigLayerEntry>, 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<Item = CloudConfigFragment>,
base_dir: &AbsolutePathBuf,
) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true)
}
fn cloud_config_layers_from_fragments_impl(
fragments: impl IntoIterator<Item = CloudConfigFragment>,
base_dir: &AbsolutePathBuf,
strict_config: bool,
) -> Result<Vec<ConfigLayerEntry>, 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<CloudConfigLayerError> for io::Error {
fn from(error: CloudConfigLayerError) -> Self {
io::Error::new(io::ErrorKind::InvalidData, error)
@@ -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();
-105
View File
@@ -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<u16>,
}
impl CloudRequirementsLoadError {
pub fn new(
code: CloudRequirementsLoadErrorCode,
status_code: Option<u16>,
message: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
status_code,
}
}
pub fn code(&self) -> CloudRequirementsLoadErrorCode {
self.code
}
pub fn status_code(&self) -> Option<u16> {
self.status_code
}
}
#[derive(Clone)]
pub struct CloudRequirementsLoader {
fut: Shared<
BoxFuture<'static, Result<Option<ConfigRequirementsToml>, CloudRequirementsLoadError>>,
>,
}
impl CloudRequirementsLoader {
pub fn new<F>(fut: F) -> Self
where
F: Future<Output = Result<Option<ConfigRequirementsToml>, CloudRequirementsLoadError>>
+ Send
+ 'static,
{
Self {
fut: fut.boxed().shared(),
}
}
pub async fn get(&self) -> Result<Option<ConfigRequirementsToml>, 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);
}
}
+14 -31
View File
@@ -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::<ConfigRequirementsToml>(
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)?);
+10 -4
View File
@@ -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;
+15 -11
View File
@@ -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<String, ConfigLayerMetadata>`
@@ -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?;
+16 -31
View File
@@ -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<Option<RequirementsLayerEntry>> {
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<Option<ConfigRequirementsToml>> {
fn load_managed_admin_requirements() -> io::Result<Option<String>> {
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<ConfigRequirementsToml> {
toml::from_str::<ConfigRequirementsToml>(&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<String> {
decode_managed_preferences_base64(encoded)
}
fn decode_managed_preferences_base64(encoded: &str) -> io::Result<String> {
+160 -138
View File
@@ -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::<ConfigToml>(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}/<name>.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<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
options: impl Into<ConfigLoadOptions>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
) -> io::Result<ConfigLayerStack> {
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<Option<RequirementsLayerEntry>> {
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<PathBuf> {
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<Vec<RequirementsLayerEntry>> {
// 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<TomlValue> {
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<T: serde::Serialize>(value: T) -> io::Result<TomlValue> {
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<SandboxMode>,
}
impl From<LegacyManagedConfigToml> 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)]
-3
View File
@@ -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
+9 -16
View File
@@ -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<F>(
&self,
profile_name: &str,
mut parent_profile: F,
) -> Result<ResolvedPermissionProfileToml, PermissionProfileResolutionError>
) -> Result<PermissionProfileToml, PermissionProfileResolutionError>
where
F: FnMut(&str) -> Option<PermissionProfileToml>,
{
@@ -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<NetworkToml>,
}
#[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<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum PermissionProfileResolutionError {
#[error("default_permissions refers to undefined profile `{profile_name}`")]
+5 -2
View File
@@ -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<LoaderOverrides> for ConfigLoadOptions {
@@ -28,6 +30,7 @@ impl From<LoaderOverrides> 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(
-1
View File
@@ -37,7 +37,6 @@ pub(crate) fn config_error_from_ignored_toml_value_fields<T: DeserializeOwned>(
)
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name<T: DeserializeOwned>(
source_name: &str,
contents: &str,
+80
View File
@@ -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<String>) -> Self {
Self::default().add_enterprise_requirement(contents)
}
pub fn enterprise_config(contents: impl Into<String>) -> Self {
Self::default().add_enterprise_config(contents)
}
pub fn loader_with_enterprise_requirement(
contents: impl Into<String>,
) -> CloudConfigBundleLoader {
Self::enterprise_requirement(contents).into_loader()
}
pub fn loader_with_enterprise_config(contents: impl Into<String>) -> CloudConfigBundleLoader {
Self::enterprise_config(contents).into_loader()
}
pub fn add_enterprise_requirement(mut self, contents: impl Into<String>) -> 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<String>) -> 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;
+25
View File
@@ -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(),
},
]
);
}
@@ -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
+1
View File
@@ -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,
};
+5 -1
View File
@@ -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
+2
View File
@@ -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);
+1
View File
@@ -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())),
);
+1
View File
@@ -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())),
);
+291 -205
View File
@@ -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<ConfigRequirementsWithSources> {
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<_>>(),
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?;
+237 -225
View File
@@ -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?;
+6 -8
View File
@@ -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<ConfigOverrides>,
loader_overrides: Option<LoaderOverrides>,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
fallback_cwd: Option<PathBuf>,
}
@@ -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?;
+125 -35
View File
@@ -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<ResolvedPermissionProfileToml> {
) -> io::Result<PermissionProfileToml> {
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<PermissionProfileToml> {
matches!(
profile_name,
BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE
)
.then_some(PermissionProfileToml {
fn extensible_builtin_parent_profile(profile_name: &str) -> Option<PermissionProfileToml> {
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<String, FilesystemPermissionToml>,
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<String, FilesystemPermissionToml>,
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<String, FilesystemPermissionToml>,
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<String>,
) -> 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<FileSystemSpecialPath> {
":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))
}
@@ -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]
+63 -92
View File
@@ -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");
+1 -4
View File
@@ -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,
+1 -3
View File
@@ -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<LayerMtime
/*cwd*/ None,
&cli_overrides,
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await
@@ -210,7 +208,7 @@ fn selected_network_from_tables(parsed: NetworkTablesToml) -> Result<Option<Netw
.context("default_permissions requires a `[permissions]` table for network settings")?;
let profile = resolve_permission_profile(&permissions, &default_permissions)
.map_err(anyhow::Error::from)?;
Ok(profile.profile.network)
Ok(profile.network)
}
#[cfg(test)]
+72 -30
View File
@@ -45,6 +45,7 @@ use chrono::Local;
use chrono::Utc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::SubAgentThreadStartedInput;
use codex_analytics::TurnCodexErrorFact;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_config::types::OAuthCredentialsStoreMode;
@@ -1634,6 +1635,17 @@ impl Session {
)
}
/// Record a terminal CodexErr before the app-server completion notification is reduced.
pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) {
self.services
.analytics_events_client
.track_turn_codex_error(TurnCodexErrorFact::from_codex_err(
self.conversation_id.to_string(),
turn_context.sub_id.clone(),
error,
));
}
/// Persist the event to rollout and send it to clients.
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
let legacy_source = msg.clone();
@@ -2100,34 +2112,16 @@ impl Session {
rx_approve
}
pub async fn request_permissions(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
call_id: String,
args: RequestPermissionsArgs,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
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<Self>,
turn_context: &Arc<TurnContext>,
call_id: String,
args: RequestPermissionsArgs,
cwd: AbsolutePathBuf,
environment: TurnEnvironmentSelection,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
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<Self>,
turn_context: &Arc<TurnContext>,
call_id: String,
args: RequestPermissionsArgs,
cwd: AbsolutePathBuf,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
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<Mutex<crate::state::TurnState>>>,
) {
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<AdditionalPermissionProfile> {
pub(crate) async fn granted_turn_permissions(
&self,
environment_id: &str,
) -> Option<AdditionalPermissionProfile> {
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<AdditionalPermissionProfile> {
pub(crate) async fn granted_session_permissions(
&self,
environment_id: &str,
) -> Option<AdditionalPermissionProfile> {
let state = self.state.lock().await;
state.granted_permissions()
state.granted_permissions(environment_id)
}
#[expect(
+258 -11
View File
@@ -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
@@ -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);
+4 -1
View File
@@ -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,
});
}
+24 -7
View File
@@ -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<SessionStartupPrewarmHandle>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) pending_session_start_sources: VecDeque<codex_hooks::SessionStartSource>,
granted_permissions: Option<AdditionalPermissionProfile>,
granted_permissions_by_environment_id: HashMap<String, AdditionalPermissionProfile>,
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<AdditionalPermissionProfile> {
self.granted_permissions.clone()
pub(crate) fn granted_permissions(
&self,
environment_id: &str,
) -> Option<AdditionalPermissionProfile> {
self.granted_permissions_by_environment_id
.get(environment_id)
.cloned()
}
}
+25 -9
View File
@@ -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<String, oneshot::Sender<DynamicToolResponse>>,
pub(crate) pending_input: TurnInputQueue,
mailbox_delivery_phase: MailboxDeliveryPhase,
granted_permissions: Option<AdditionalPermissionProfile>,
granted_permissions_by_environment_id: HashMap<String, AdditionalPermissionProfile>,
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<RequestPermissionsResponse>,
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<AdditionalPermissionProfile> {
self.granted_permissions.clone()
pub(crate) fn granted_permissions(
&self,
environment_id: &str,
) -> Option<AdditionalPermissionProfile> {
self.granted_permissions_by_environment_id
.get(environment_id)
.cloned()
}
pub(crate) fn enable_strict_auto_review(&mut self) {
+45 -27
View File
@@ -131,6 +131,7 @@ pub(crate) async fn persist_image_generation_item(
turn_context: &TurnContext,
image_item: &mut ImageGenerationItem,
) -> Option<AbsolutePathBuf> {
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::<String>();
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::<String>();
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,
@@ -265,6 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option<String> {
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<ToolInvocation> 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
{
@@ -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<Mutex<Option<String>>>,
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::<ExtensionTurnItemContributorRan>()
.is_some()
);
}
#[async_trait::async_trait]
@@ -434,15 +452,28 @@ mod tests {
&self,
call: codex_tools::ToolCall,
) -> Result<Box<dyn codex_tools::ToolOutput>, 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(),
))
);
}
}
+4 -3
View File
@@ -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<AdditionalPermissionProfile>,
) -> 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(),
@@ -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<String>,
}
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
fn tool_name(&self) -> ToolName {
@@ -48,9 +57,16 @@ impl ToolExecutor<ToolInvocation> 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<ToolInvocation> 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(
+2 -2
View File
@@ -84,10 +84,10 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
#[allow(deprecated)]
let effective_additional_permissions = apply_granted_turn_permissions(
session.as_ref(),
turn.cwd.as_path(),
&turn_environment.environment_id,
exec_params.cwd.as_path(),
exec_params.sandbox_permissions,
additional_permissions,
)
@@ -229,6 +229,13 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
"Optional short explanation for why additional permissions are needed.".to_string(),
)),
),
(
"environment_id".to_string(),
JsonSchema::string(Some(
"Environment id from <environment_context>. 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()
}
@@ -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 <environment_context>. Omit to use the primary environment."
.to_string(),
)),
),
("permissions".to_string(), permission_profile_schema()),
]);
@@ -173,6 +173,7 @@ impl ToolExecutor<ToolInvocation> 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,

Some files were not shown because too many files have changed in this diff Show More