Merge 964eb85a88 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-02 14:18:01 -07:00
committed by GitHub
81 changed files with 3086 additions and 2468 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
+3 -13
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.
@@ -164,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
-2
View File
@@ -2356,7 +2356,6 @@ dependencies = [
"codex-login",
"codex-otel",
"codex-protocol",
"codex-utils-absolute-path",
"hmac 0.12.1",
"pretty_assertions",
"serde",
@@ -2365,7 +2364,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
]
@@ -391,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,
}
}
@@ -3255,6 +3256,7 @@ 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),
@@ -3320,6 +3322,7 @@ 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",
@@ -3640,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));
+1
View File
@@ -794,6 +794,7 @@ 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>,
+1
View File
@@ -85,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,
}
+2
View File
@@ -2483,6 +2483,7 @@ 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();
@@ -2515,6 +2516,7 @@ 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,
+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,
+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,
@@ -4,7 +4,7 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -76,7 +76,8 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
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
@@ -157,7 +158,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
}
async fn wait_for_image_generation_completed(
mcp: &mut McpProcess,
mcp: &mut TestAppServer,
) -> Result<ItemCompletedNotification> {
loop {
let notification = mcp
@@ -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,
@@ -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 -3
View File
@@ -17,14 +17,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]
File diff suppressed because it is too large Load Diff
+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() {
@@ -1510,22 +1506,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,
@@ -1906,7 +1886,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()
@@ -2252,8 +2232,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(
@@ -2294,7 +2277,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()
@@ -2415,7 +2398,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)?;
@@ -2725,7 +2708,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"]
@@ -2816,7 +2799,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"]
@@ -3003,7 +2986,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]
@@ -3047,7 +3030,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(())
}
@@ -3111,7 +3094,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)?);
@@ -3184,7 +3167,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
+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
+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(),
})
);
@@ -1676,6 +1613,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()?;
@@ -1702,18 +1721,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?;
@@ -1743,36 +1759,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?;
@@ -1784,12 +1800,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()?;
@@ -1797,23 +1895,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?;
@@ -1837,7 +1936,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?;
@@ -1848,21 +1947,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(())
}
@@ -1905,7 +2006,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -1988,7 +2088,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?;
@@ -2070,7 +2169,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?;
@@ -2141,7 +2239,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?;
@@ -2329,7 +2426,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?;
@@ -2370,7 +2466,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?;
@@ -2436,7 +2531,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?;
@@ -2512,7 +2606,6 @@ profile = "ignored"
Some(cwd.clone()),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -2559,7 +2652,6 @@ profile = "ignored"
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -2648,7 +2740,6 @@ wire_api = "responses"
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -2750,7 +2841,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?;
@@ -2916,7 +3006,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?;
@@ -2985,7 +3074,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?;
@@ -3046,7 +3134,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?;
@@ -3092,7 +3179,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?;
+194 -224
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 {
@@ -3976,7 +3976,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(),
@@ -4026,7 +4026,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(),
@@ -4429,7 +4429,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()
@@ -4466,27 +4466,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());
@@ -4507,7 +4496,10 @@ enabled = true
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
source: RequirementSource::EnterpriseManaged {
id: "req_1".to_string(),
name: "Base requirements".to_string(),
},
})
))
);
@@ -4548,15 +4540,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());
@@ -4570,7 +4562,10 @@ enabled = true
Some((
false,
Some(McpServerDisabledReason::Requirements {
source: RequirementSource::CloudRequirements,
source: RequirementSource::EnterpriseManaged {
id: "req_1".to_string(),
name: "Base requirements".to_string(),
},
})
))
);
@@ -5188,7 +5183,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
Some(cwd),
&Vec::new(),
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -5322,7 +5316,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?;
@@ -9025,12 +9018,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!(
@@ -9049,35 +9041,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!(
@@ -9097,21 +9068,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?;
@@ -9142,12 +9108,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");
@@ -9177,12 +9142,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");
@@ -9199,11 +9163,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()))
@@ -9211,9 +9170,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?;
@@ -9229,11 +9190,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()))
@@ -9241,9 +9197,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?;
@@ -9353,14 +9311,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?;
@@ -9394,12 +9349,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?;
@@ -9423,12 +9377,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!(
@@ -9444,17 +9397,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?;
@@ -9478,14 +9429,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?;
@@ -9500,17 +9451,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?;
@@ -9606,17 +9555,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?;
@@ -9726,12 +9673,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?;
@@ -9752,12 +9698,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?;
@@ -9773,6 +9718,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<()> {
@@ -9786,15 +9762,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?;
@@ -10280,17 +10252,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?;
@@ -10316,14 +10286,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?;
@@ -10346,14 +10316,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;
@@ -1106,7 +1106,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>,
}
@@ -1137,8 +1137,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
}
@@ -1167,7 +1167,7 @@ impl ConfigBuilder {
harness_overrides,
loader_overrides,
strict_config,
cloud_requirements,
cloud_config_bundle,
thread_config_loader,
fallback_cwd,
} = self;
@@ -1192,8 +1192,8 @@ impl ConfigBuilder {
ConfigLoadOptions {
loader_overrides,
strict_config,
cloud_config_bundle,
},
cloud_requirements,
thread_config_loader
.as_deref()
.unwrap_or(&codex_config::NoopThreadConfigLoader),
@@ -1617,7 +1617,6 @@ pub async fn load_config_as_toml_with_cli_and_load_options(
cwd.cloned(),
&cli_overrides,
options,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
@@ -1828,7 +1827,6 @@ pub async fn load_global_mcp_servers(
cwd,
&cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
+33 -55
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");
@@ -541,7 +539,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),
@@ -552,25 +550,17 @@ enabled = true
)
.expect("write config");
let requirements = ConfigRequirementsToml {
apps: Some(AppsRequirementsToml {
apps: BTreeMap::from([(
"connector_123123".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
..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");
@@ -592,29 +582,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),
tools: None,
},
)]),
}),
..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");
@@ -870,7 +852,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),
@@ -881,21 +863,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");
@@ -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
+2 -2
View File
@@ -1040,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 {
@@ -7101,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
+1
View File
@@ -640,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,
});
}
+9
View File
@@ -33,6 +33,7 @@ const MODEL_KEY: &str = "model";
const REASONING_EFFORT_KEY: &str = "reasoning_effort";
const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms";
const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn";
const WORKSPACE_KIND_KEY: &str = "workspace_kind";
const REQUEST_KIND_KEY: &str = "request_kind";
const COMPACTION_KEY: &str = "compaction";
const WINDOW_ID_KEY: &str = "window_id";
@@ -443,6 +444,14 @@ impl TurnMetadataState {
Some(responsesapi_client_metadata);
}
pub(crate) fn workspace_kind(&self) -> Option<String> {
self.responsesapi_client_metadata
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(|metadata| metadata.get(WORKSPACE_KIND_KEY).cloned())
}
pub(crate) fn set_turn_started_at_unix_ms(&self, turn_started_at_unix_ms: i64) {
*self
.turn_started_at_unix_ms
+3
View File
@@ -546,6 +546,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
state.set_responsesapi_client_metadata(HashMap::from([
("fiber_run_id".to_string(), "fiber-123".to_string()),
("origin".to_string(), "東京".to_string()),
("workspace_kind".to_string(), "projectless".to_string()),
("model".to_string(), "client-supplied".to_string()),
(
"reasoning_effort".to_string(),
@@ -580,6 +581,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123"));
assert_eq!(json["origin"].as_str(), Some("東京"));
assert_eq!(json["workspace_kind"].as_str(), Some("projectless"));
assert_eq!(json["model"].as_str(), Some("client-supplied"));
assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied"));
assert_eq!(json["session_id"].as_str(), Some("session-a"));
@@ -619,6 +621,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert_eq!(meta["model"].as_str(), Some("gpt-5.4"));
assert_eq!(meta["reasoning_effort"].as_str(), Some("high"));
assert!(meta.get(WINDOW_ID_KEY).is_none());
assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
}
#[test]
+16 -20
View File
@@ -8,10 +8,9 @@ use ctor::ctor;
use std::sync::OnceLock;
use tempfile::TempDir;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigRequirementsToml;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::NetworkRequirementsToml;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::CodexThread;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -169,40 +168,37 @@ pub fn fetch_dotslash_file(
/// temporary directory. Using a per-test directory keeps tests hermetic and
/// avoids clobbering a developer’s real `~/.codex`.
pub async fn load_default_config_for_test(codex_home: &TempDir) -> Config {
load_default_config_for_test_with_cloud_requirements(
load_default_config_for_test_with_cloud_config_bundle(
codex_home,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
)
.await
}
/// Returns a default `Config` with test-provided cloud requirements applied
/// Returns a default `Config` with test-provided cloud bundle requirements applied.
/// during config construction.
pub async fn load_default_config_for_test_with_cloud_requirements(
pub async fn load_default_config_for_test_with_cloud_config_bundle(
codex_home: &TempDir,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
) -> Config {
ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(default_test_overrides())
.cloud_requirements(cloud_requirements)
.cloud_config_bundle(cloud_config_bundle)
.build()
.await
.expect("defaults for test should always succeed")
}
pub fn managed_network_requirements_loader() -> CloudRequirementsLoader {
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
network: Some(NetworkRequirementsToml {
enabled: Some(true),
allow_local_binding: Some(true),
..Default::default()
}),
..Default::default()
}))
})
pub fn managed_network_requirements_loader() -> CloudConfigBundleLoader {
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[experimental_network]
enabled = true
allow_local_binding = true
"#,
)
}
#[cfg(target_os = "linux")]
+11 -8
View File
@@ -12,7 +12,7 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::config::Config;
@@ -49,7 +49,7 @@ use crate::PathBufExt;
use crate::TempDirExt;
use crate::get_remote_test_env;
use crate::load_default_config_for_test;
use crate::load_default_config_for_test_with_cloud_requirements;
use crate::load_default_config_for_test_with_cloud_config_bundle;
use crate::responses::WebSocketTestServer;
use crate::responses::output_value_to_text;
use crate::responses::start_mock_server;
@@ -213,7 +213,7 @@ pub struct TestCodexBuilder {
pre_build_hooks: Vec<Box<PreBuildHook>>,
workspace_setups: Vec<Box<WorkspaceSetup>>,
home: Option<Arc<TempDir>>,
cloud_requirements: Option<CloudRequirementsLoader>,
cloud_config_bundle: Option<CloudConfigBundleLoader>,
user_shell_override: Option<Shell>,
exec_server_url: Option<String>,
}
@@ -262,8 +262,11 @@ impl TestCodexBuilder {
self
}
pub fn with_cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self {
self.cloud_requirements = Some(cloud_requirements);
pub fn with_cloud_config_bundle(
mut self,
cloud_config_bundle: CloudConfigBundleLoader,
) -> Self {
self.cloud_config_bundle = Some(cloud_config_bundle);
self
}
@@ -545,8 +548,8 @@ impl TestCodexBuilder {
for hook in self.pre_build_hooks.drain(..) {
hook(home.path());
}
let mut config = if let Some(cloud_requirements) = self.cloud_requirements.take() {
load_default_config_for_test_with_cloud_requirements(home, cloud_requirements).await
let mut config = if let Some(cloud_config_bundle) = self.cloud_config_bundle.take() {
load_default_config_for_test_with_cloud_config_bundle(home, cloud_config_bundle).await
} else {
load_default_config_for_test(home).await
};
@@ -1031,7 +1034,7 @@ pub fn test_codex() -> TestCodexBuilder {
pre_build_hooks: vec![],
workspace_setups: vec![],
home: None,
cloud_requirements: None,
cloud_config_bundle: None,
user_shell_override: None,
exec_server_url: None,
}
+3 -3
View File
@@ -2999,7 +2999,7 @@ allow_local_binding = true
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex()
.with_home(home)
.with_cloud_requirements(managed_network_requirements_loader())
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
@@ -3279,7 +3279,7 @@ allow_local_binding = true
};
let mut builder = test_codex()
.with_home(home)
.with_cloud_requirements(managed_network_requirements_loader())
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
});
@@ -3484,7 +3484,7 @@ allow_local_binding = true
};
let mut builder = test_codex()
.with_home(home)
.with_cloud_requirements(managed_network_requirements_loader())
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
let cwd = config.cwd.clone();
+1 -1
View File
@@ -2229,7 +2229,7 @@ allow_local_binding = true
panic!("failed to write permission request hook test fixture: {error}");
}
})
.with_cloud_requirements(managed_network_requirements_loader())
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
trust_discovered_hooks(config);
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
+1 -1
View File
@@ -916,7 +916,7 @@ allow_local_binding = true
let permission_profile = permission_profile_for_config.clone();
let mut builder = test_codex()
.with_home(home)
.with_cloud_requirements(managed_network_requirements_loader())
.with_cloud_config_bundle(managed_network_requirements_loader())
.with_config(move |config| {
config.use_experimental_unified_exec_tool = true;
config
+75 -33
View File
@@ -51,7 +51,8 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_requirements_loader_for_storage;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadError;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
@@ -330,53 +331,54 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
..Default::default()
};
let config_toml = match load_config_as_toml_with_cli_and_load_options(
let bootstrap_config_toml = load_config_toml_or_exit(
&codex_home,
Some(&config_cwd),
cli_kv_overrides.clone(),
ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
loader_overrides.clone(),
strict_config,
CloudConfigBundleLoader::default(),
)
.await
{
Ok(config_toml) => config_toml,
Err(err) => {
let config_error = err
.get_ref()
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
.map(ConfigLoadError::config_error);
if let Some(config_error) = config_error {
eprintln!(
"Error loading config.toml:\n{}",
format_config_error_with_source(config_error)
);
} else {
eprintln!("Error loading config.toml: {err}");
}
std::process::exit(1);
}
};
.await;
let chatgpt_base_url = config_toml
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
// TODO(gt): Make cloud requirements failures blocking once we can fail-closed.
let cloud_requirements = cloud_requirements_loader_for_storage(
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
chatgpt_base_url,
)
.await;
let run_cli_overrides = cli_kv_overrides.clone();
let run_loader_overrides = loader_overrides.clone();
let run_cloud_requirements = cloud_requirements.clone();
let run_cloud_config_bundle = cloud_config_bundle.clone();
let model_provider = if oss {
let resolved = resolve_oss_provider(oss_provider.as_deref(), &config_toml);
let config_toml_with_cloud_config;
let config_toml_for_oss = if oss_provider.is_none() {
// The first load intentionally skips cloud config so we can read
// auth/base-url settings needed to fetch the bundle. If OSS mode
// needs a default provider from config, reload with the bundle.
config_toml_with_cloud_config = load_config_toml_or_exit(
&codex_home,
Some(&config_cwd),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_config_bundle.clone(),
)
.await;
&config_toml_with_cloud_config
} else {
&bootstrap_config_toml
};
let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss);
if let Some(provider) = resolved {
Some(provider)
@@ -437,7 +439,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.harness_overrides(overrides)
.loader_overrides(loader_overrides.clone())
.strict_config(strict_config)
.cloud_requirements(cloud_requirements.clone())
.cloud_config_bundle(cloud_config_bundle.clone())
.build()
};
let config = build_exec_config(
@@ -536,7 +538,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
cli_overrides: run_cli_overrides,
loader_overrides: run_loader_overrides,
strict_config,
cloud_requirements: run_cloud_requirements,
cloud_config_bundle: run_cloud_config_bundle,
feedback: CodexFeedback::new(),
log_db: None,
state_db: state_db.clone(),
@@ -606,6 +608,46 @@ where
}
}
#[allow(clippy::print_stderr)]
async fn load_config_toml_or_exit(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_kv_overrides: Vec<(String, codex_config::TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_config_bundle: CloudConfigBundleLoader,
) -> codex_config::config_toml::ConfigToml {
match load_config_as_toml_with_cli_and_load_options(
codex_home,
cwd,
cli_kv_overrides,
ConfigLoadOptions {
loader_overrides,
strict_config,
cloud_config_bundle,
},
)
.await
{
Ok(config_toml) => config_toml,
Err(err) => {
let config_error = err
.get_ref()
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
.map(ConfigLoadError::config_error);
if let Some(config_error) = config_error {
eprintln!(
"Error loading config.toml:\n{}",
format_config_error_with_source(config_error)
);
} else {
eprintln!("Error loading config.toml: {err}");
}
std::process::exit(1);
}
}
}
async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
let ExecRunArgs {
in_process_start_args,
+1
View File
@@ -578,6 +578,7 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile()
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
-4
View File
@@ -274,9 +274,6 @@ fn fallback_managed_hooks_source_path(
Some(RequirementSource::MdmManagedPreferences { domain, key }) => {
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/requirements.toml"))
}
Some(RequirementSource::CloudRequirements) => {
synthetic_layer_path("<cloud-requirements>/requirements.toml")
}
Some(RequirementSource::Composite { .. }) => {
synthetic_layer_path("<requirements-composition>/requirements.toml")
}
@@ -635,7 +632,6 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
HookSource::LegacyManagedConfigMdm
}
Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements,
Some(RequirementSource::Composite { sources }) => {
// Requirements hook composition preserves contributing sources in
// priority order, but discovery only carries one source for the
+20 -9
View File
@@ -120,12 +120,12 @@ fn requirements_with_managed_hooks_only(
ConfigRequirements {
allow_managed_hooks_only: Some(Sourced::new(
allow_managed_hooks_only,
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
managed_hooks: managed_hooks.clone().map(|hooks| {
ConstrainedWithSource::new(
Constrained::allow_any(hooks),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)
}),
..ConfigRequirements::default()
@@ -183,7 +183,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
..ConfigRequirements::default()
},
@@ -208,7 +208,10 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements);
assert_eq!(
engine.handlers[0].source,
HookSource::LegacyManagedConfigMdm
);
let listed = crate::list_hooks(crate::HooksConfig {
legacy_notify_argv: None,
feature_enabled: true,
@@ -286,7 +289,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
..ConfigRequirements::default()
},
@@ -447,7 +450,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
..ConfigRequirements::default()
},
@@ -471,7 +474,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
);
assert_eq!(engine.handlers.len(), 1);
assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements);
assert_eq!(
engine.handlers[0].source,
HookSource::LegacyManagedConfigMdm
);
let discovered = super::discovery::discover_handlers(
Some(&config_layer_stack),
Vec::new(),
@@ -671,7 +677,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
..ConfigRequirements::default()
},
@@ -1237,7 +1243,12 @@ print(json.dumps({
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed);
assert_eq!(
outcome.hook_events[0].run.status,
HookRunStatus::Completed,
"hook entries: {:#?}",
outcome.hook_events[0].run.entries
);
assert_eq!(outcome.hook_events[0].run.entries.len(), 1);
assert_eq!(
outcome.hook_events[0].run.entries[0].kind,
+46 -36
View File
@@ -628,7 +628,7 @@ mod tests {
let requirements = ConfigRequirements {
approval_policy: ConstrainedWithSource::new(
Constrained::allow_any(AskForApproval::OnRequest.to_core()),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
),
approvals_reviewer: ConstrainedWithSource::new(
Constrained::allow_any(ApprovalsReviewer::AutoReview),
@@ -653,25 +653,25 @@ mod tests {
)),
enforce_residency: ConstrainedWithSource::new(
Constrained::allow_any(Some(ResidencyRequirement::Us)),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
),
web_search_mode: ConstrainedWithSource::new(
Constrained::allow_any(WebSearchMode::Cached),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
),
allow_managed_hooks_only: Some(Sourced::new(
/*value*/ true,
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
allow_appshots: Some(Sourced::new(
/*value*/ false,
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
feature_requirements: Some(Sourced::new(
FeatureRequirementsToml {
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
},
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
network: Some(Sourced::new(
NetworkConstraints {
@@ -684,7 +684,7 @@ mod tests {
}),
..Default::default()
},
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
filesystem: Some(Sourced::new(
FilesystemConstraints {
@@ -694,7 +694,7 @@ mod tests {
file: requirements_file.clone(),
},
)),
guardian_policy_config_source: Some(RequirementSource::CloudRequirements),
guardian_policy_config_source: Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
..ConfigRequirements::default()
};
@@ -749,9 +749,10 @@ mod tests {
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
assert!(
rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)")
);
let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string();
assert!(rendered.contains(&format!(
"allowed_approval_policies: on-request (source: {requirements_source})"
)));
assert!(rendered.contains(
"allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))"
));
@@ -764,22 +765,28 @@ mod tests {
.as_str(),
)
);
assert!(
rendered.contains(
"allowed_web_search_modes: cached, disabled (source: cloud requirements)"
)
);
assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)"));
assert!(rendered.contains("allow_appshots: false (source: cloud requirements)"));
assert!(
rendered.contains("guardian_policy_config: configured (source: cloud requirements)")
);
assert!(rendered.contains("features: guardian_approval=true (source: cloud requirements)"));
assert!(rendered.contains(&format!(
"allowed_web_search_modes: cached, disabled (source: {requirements_source})"
)));
assert!(rendered.contains(&format!(
"allow_managed_hooks_only: true (source: {requirements_source})"
)));
assert!(rendered.contains(&format!(
"allow_appshots: false (source: {requirements_source})"
)));
assert!(rendered.contains(&format!(
"guardian_policy_config: configured (source: {requirements_source})"
)));
assert!(rendered.contains(&format!(
"features: guardian_approval=true (source: {requirements_source})"
)));
assert!(rendered.contains("mcp_servers: docs (source: MDM managed_config.toml (legacy))"));
assert!(rendered.contains("enforce_residency: us (source: cloud requirements)"));
assert!(rendered.contains(
"experimental_network: enabled=true, domains={example.com=allow} (source: cloud requirements)"
));
assert!(rendered.contains(&format!(
"enforce_residency: us (source: {requirements_source})"
)));
assert!(rendered.contains(&format!(
"experimental_network: enabled=true, domains={{example.com=allow}} (source: {requirements_source})"
)));
assert!(
rendered.contains(
format!(
@@ -834,7 +841,7 @@ mod tests {
}),
..Default::default()
},
RequirementSource::CloudRequirements,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
..ConfigRequirements::default()
};
@@ -844,9 +851,10 @@ mod tests {
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
assert!(rendered.contains(
"experimental_network: unix_sockets={/tmp/blocked.sock=deny, /tmp/codex.sock=allow} (source: cloud requirements)"
));
let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string();
assert!(rendered.contains(&format!(
"experimental_network: unix_sockets={{/tmp/blocked.sock=deny, /tmp/codex.sock=allow}} (source: {requirements_source})"
)));
}
#[test]
@@ -956,7 +964,7 @@ approval_policy = "never"
let requirements = ConfigRequirements {
web_search_mode: ConstrainedWithSource::new(
Constrained::allow_any(WebSearchMode::Disabled),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
),
..ConfigRequirements::default()
};
@@ -988,9 +996,10 @@ approval_policy = "never"
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
assert!(
rendered.contains("allowed_web_search_modes: disabled (source: cloud requirements)")
);
let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string();
assert!(rendered.contains(&format!(
"allowed_web_search_modes: disabled (source: {requirements_source})"
)));
}
#[test]
@@ -1018,7 +1027,7 @@ approval_policy = "never"
..Default::default()
},
}),
Some(RequirementSource::CloudRequirements),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
)),
..ConfigRequirements::default()
};
@@ -1033,9 +1042,10 @@ approval_policy = "never"
.expect("config layer stack");
let rendered = render_to_text(&render_debug_config_lines(&stack));
let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string();
assert!(rendered.contains("hooks:"));
assert!(rendered.contains("handlers=1"));
assert!(rendered.contains("(source: cloud requirements)"));
assert!(rendered.contains(&format!("(source: {requirements_source})")));
}
#[test]
+110 -62
View File
@@ -38,8 +38,8 @@ use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_cloud_config::cloud_requirements_loader_for_storage;
use codex_config::CloudRequirementsLoader;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadError;
use codex_config::LoaderOverrides;
use codex_config::format_config_error_with_source;
@@ -298,7 +298,7 @@ async fn start_embedded_app_server(
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
@@ -310,7 +310,7 @@ async fn start_embedded_app_server(
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
cloud_config_bundle,
feedback,
log_db,
state_db,
@@ -510,7 +510,7 @@ async fn start_app_server(
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
@@ -523,7 +523,7 @@ async fn start_app_server(
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
cloud_config_bundle,
feedback,
log_db,
state_db,
@@ -550,7 +550,7 @@ pub(crate) async fn start_app_server_for_picker(
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
state_db,
@@ -584,7 +584,7 @@ async fn start_embedded_app_server_with<F, Fut>(
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
@@ -611,7 +611,7 @@ where
cli_overrides: cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
cloud_config_bundle,
feedback,
log_db,
state_db,
@@ -977,51 +977,58 @@ pub async fn run_main(
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
#[allow(clippy::print_stderr)]
let config_toml = match load_config_as_toml_with_cli_and_load_options(
let bootstrap_config_toml = load_config_toml_or_exit(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
loader_overrides.clone(),
strict_config,
CloudConfigBundleLoader::default(),
)
.await
{
Ok(config_toml) => config_toml,
Err(err) => {
let config_error = err
.get_ref()
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
.map(ConfigLoadError::config_error);
if let Some(config_error) = config_error {
eprintln!(
"Error loading config.toml:\n{}",
format_config_error_with_source(config_error)
);
} else {
eprintln!("Error loading config.toml: {err}");
}
std::process::exit(1);
}
};
.await;
let chatgpt_base_url = config_toml
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let cloud_requirements = cloud_requirements_loader_for_storage(
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
chatgpt_base_url,
)
.await;
let cwd_override = if app_server_target.uses_remote_workspace() {
None
} else {
cwd.clone()
};
let mut manually_selected_oss_provider = None;
let model_provider_override = if cli.oss {
let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml);
let config_toml_with_cloud_config;
let config_toml_for_oss = if cli.oss_provider.is_none() {
// The first load intentionally skips cloud config so we can read
// auth/base-url settings needed to fetch the bundle. If OSS mode
// needs a default provider from config, reload with the bundle.
config_toml_with_cloud_config = load_config_toml_or_exit(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_config_bundle.clone(),
)
.await;
&config_toml_with_cloud_config
} else {
&bootstrap_config_toml
};
let resolved = resolve_oss_provider(cli.oss_provider.as_deref(), config_toml_for_oss);
if let Some(provider) = resolved {
Some(provider)
@@ -1062,11 +1069,7 @@ pub async fn run_main(
model,
approval_policy,
sandbox_mode,
cwd: if app_server_target.uses_remote_workspace() {
None
} else {
cwd
},
cwd: cwd_override,
model_provider: model_provider_override.clone(),
codex_self_exe: arg0_paths.codex_self_exe.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
@@ -1081,7 +1084,7 @@ pub async fn run_main(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await;
@@ -1137,7 +1140,7 @@ pub async fn run_main(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await;
@@ -1156,6 +1159,11 @@ pub async fn run_main(
tracing::warn!(error = %err, "failed to deserialize config for personality migration");
}
}
let config_toml_log_dir_configured = config
.config_layer_stack
.effective_config()
.as_table()
.is_some_and(|table| table.contains_key("log_dir"));
#[allow(clippy::print_stderr)]
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
@@ -1199,7 +1207,7 @@ pub async fn run_main(
}
}
let (tui_file_layer, _tui_file_log_guard) = if config_toml.log_dir.is_some() {
let (tui_file_layer, _tui_file_log_guard) = if config_toml_log_dir_configured {
let log_dir = config.log_dir.clone();
std::fs::create_dir_all(&log_dir)?;
let mut log_file_opts = OpenOptions::new();
@@ -1281,7 +1289,7 @@ pub async fn run_main(
manually_selected_oss_provider,
overrides,
cli_kv_overrides,
cloud_requirements,
cloud_config_bundle,
feedback,
log_db,
state_db,
@@ -1303,7 +1311,7 @@ async fn run_ratatui_app(
manually_selected_oss_provider: Option<String>,
overrides: ConfigOverrides,
cli_kv_overrides: Vec<(String, toml::Value)>,
mut cloud_requirements: CloudRequirementsLoader,
mut cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
@@ -1365,7 +1373,7 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
cloud_config_bundle.clone(),
feedback.clone(),
log_db.clone(),
state_db.clone(),
@@ -1448,11 +1456,11 @@ async fn run_ratatui_app(
{
trust_decision_was_made = onboarding_result.directory_trust_persisted;
}
// If this onboarding run included the login step, always refresh cloud requirements and
// rebuild config. This avoids missing newly available cloud requirements due to login
// If this onboarding run included the login step, always refresh the cloud config bundle
// and rebuild config. This avoids missing newly available cloud-managed policy due to login
// status detection edge cases.
if show_login_screen && !uses_remote_workspace {
cloud_requirements = cloud_requirements_loader_for_storage(
cloud_config_bundle = cloud_config_bundle_loader_for_storage(
initial_config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
initial_config.cli_auth_credentials_store_mode,
@@ -1470,7 +1478,7 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await
@@ -1674,7 +1682,7 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
cloud_config_bundle.clone(),
strict_config,
fallback_cwd,
)
@@ -1685,7 +1693,7 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await
@@ -1745,7 +1753,7 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
cloud_config_bundle.clone(),
feedback.clone(),
log_db.clone(),
state_db.clone(),
@@ -1902,14 +1910,14 @@ async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
strict_config: bool,
) -> Config {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides,
overrides,
loader_overrides,
cloud_requirements,
cloud_config_bundle,
strict_config,
/*fallback_cwd*/ None,
)
@@ -1920,7 +1928,7 @@ async fn load_config_or_exit_with_fallback_cwd(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
strict_config: bool,
fallback_cwd: Option<PathBuf>,
) -> Config {
@@ -1930,7 +1938,7 @@ async fn load_config_or_exit_with_fallback_cwd(
.harness_overrides(overrides)
.loader_overrides(loader_overrides)
.strict_config(strict_config)
.cloud_requirements(cloud_requirements)
.cloud_config_bundle(cloud_config_bundle)
.fallback_cwd(fallback_cwd)
.build()
.await
@@ -1943,6 +1951,46 @@ async fn load_config_or_exit_with_fallback_cwd(
}
}
#[allow(clippy::print_stderr)]
async fn load_config_toml_or_exit(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_kv_overrides: Vec<(String, codex_config::TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_config_bundle: CloudConfigBundleLoader,
) -> codex_config::config_toml::ConfigToml {
match load_config_as_toml_with_cli_and_load_options(
codex_home,
cwd,
cli_kv_overrides,
codex_config::ConfigLoadOptions {
loader_overrides,
strict_config,
cloud_config_bundle,
},
)
.await
{
Ok(config_toml) => config_toml,
Err(err) => {
let config_error = err
.get_ref()
.and_then(|err| err.downcast_ref::<ConfigLoadError>())
.map(ConfigLoadError::config_error);
if let Some(config_error) = config_error {
eprintln!(
"Error loading config.toml:\n{}",
format_config_error_with_source(config_error)
);
} else {
eprintln!("Error loading config.toml: {err}");
}
std::process::exit(1);
}
}
}
/// Determine if the user has decided whether to trust the current directory.
fn should_show_trust_screen(config: &Config) -> bool {
config.active_project.trust_level.is_none()
@@ -2017,7 +2065,7 @@ mod tests {
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
state_db,
@@ -2771,7 +2819,7 @@ mod tests {
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
/*state_db*/ None,
+2 -2
View File
@@ -1012,7 +1012,7 @@ mod tests {
use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_requirements_loader_for_storage;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
@@ -1033,7 +1033,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: Default::default(),
strict_config: false,
cloud_requirements: cloud_requirements_loader_for_storage(
cloud_config_bundle: cloud_config_bundle_loader_for_storage(
codex_home_path.clone(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
+6 -4
View File
@@ -16,7 +16,8 @@ use codex_app_server_protocol::Thread as AppServerThread;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_requirements_loader_for_storage;
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
@@ -251,6 +252,7 @@ async fn start_app_server_for_archive_command(
ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
cloud_config_bundle: CloudConfigBundleLoader::default(),
},
)
.await
@@ -259,7 +261,7 @@ async fn start_app_server_for_archive_command(
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let cloud_requirements = cloud_requirements_loader_for_storage(
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
@@ -298,7 +300,7 @@ async fn start_app_server_for_archive_command(
})
.loader_overrides(loader_overrides.clone())
.strict_config(strict_config)
.cloud_requirements(cloud_requirements.clone())
.cloud_config_bundle(cloud_config_bundle.clone())
.build()
.await
.wrap_err("failed to load configuration")?;
@@ -312,7 +314,7 @@ async fn start_app_server_for_archive_command(
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
cloud_config_bundle,
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
state_db,
+3 -3
View File
@@ -16,7 +16,7 @@ classifiers = [
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.132.0"]
dependencies = ["pydantic>=2.12", "openai-codex-cli-bin==0.136.0"]
[project.urls]
Homepage = "https://github.com/openai/codex"
@@ -78,10 +78,10 @@ combine-as-imports = true
[tool.uv]
exclude-newer = "7 days"
exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" }
exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" }
index-strategy = "first-index"
[tool.uv.pip]
exclude-newer = "7 days"
exclude-newer-package = { openai-codex-cli-bin = "2026-05-20T21:00:00Z" }
exclude-newer-package = { openai-codex-cli-bin = "2026-06-01T20:00:00Z" }
index-strategy = "first-index"
+4 -2
View File
@@ -1163,7 +1163,9 @@ def generate_public_api_flat_methods() -> None:
turn_start_fields = _load_public_fields(
"openai_codex.generated.v2_all",
"TurnStartParams",
exclude={"thread_id", "input", *approval_fields},
# Keep the wire model current without exposing this app-server field
# through the ergonomic Python API yet.
exclude={"thread_id", "input", "client_user_message_id", *approval_fields},
)
turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy")
@@ -1267,7 +1269,7 @@ def build_parser() -> argparse.ArgumentParser:
"--platform-tag",
help=(
"Optional wheel platform tag override, for example "
"macosx_11_0_arm64 or musllinux_1_1_x86_64."
"macosx_11_0_arm64 or manylinux_2_17_x86_64."
),
)
return parser
@@ -57,6 +57,7 @@ from .v2_all import ThreadRealtimeSdpNotification
from .v2_all import ThreadRealtimeStartedNotification
from .v2_all import ThreadRealtimeTranscriptDeltaNotification
from .v2_all import ThreadRealtimeTranscriptDoneNotification
from .v2_all import ThreadSettingsUpdatedNotification
from .v2_all import ThreadStartedNotification
from .v2_all import ThreadStatusChangedNotification
from .v2_all import ThreadTokenUsageUpdatedNotification
@@ -122,6 +123,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"thread/realtime/started": ThreadRealtimeStartedNotification,
"thread/realtime/transcript/delta": ThreadRealtimeTranscriptDeltaNotification,
"thread/realtime/transcript/done": ThreadRealtimeTranscriptDoneNotification,
"thread/settings/updated": ThreadSettingsUpdatedNotification,
"thread/started": ThreadStartedNotification,
"thread/status/changed": ThreadStatusChangedNotification,
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,
+369 -77
View File
@@ -56,7 +56,7 @@ class ActivePermissionProfile(BaseModel):
extends: Annotated[
str | None,
Field(
description="Parent profile identifier once permissions profiles support inheritance. This is currently always `null`."
description="Parent profile identifier from the selected permissions profile's `extends` setting, when present."
),
] = None
id: Annotated[
@@ -77,6 +77,11 @@ class AddCreditsNudgeEmailStatus(Enum):
cooldown_active = "cooldown_active"
class AdditionalContextKind(Enum):
untrusted = "untrusted"
application = "application"
class AdditionalNetworkPermissions(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -251,6 +256,11 @@ class AuthMode(Enum):
agent_identity = "agentIdentity"
class AutoCompactTokenLimitScope(Enum):
total = "total"
body_after_prefix = "body_after_prefix"
class AutoReviewDecisionSource(RootModel[Literal["agent"]]):
model_config = ConfigDict(
populate_by_name=True,
@@ -563,6 +573,13 @@ class CommandMigration(BaseModel):
name: str
class ComputerUseRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
allow_locked_computer_use: Annotated[bool | None, Field(alias="allowLockedComputerUse")] = None
class MdmConfigLayerSource(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -675,7 +692,7 @@ class ConfigReadParams(BaseModel):
description="Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root)."
),
] = None
include_layers: Annotated[bool | None, Field(alias="includeLayers")] = False
include_layers: Annotated[bool | None, Field(alias="includeLayers")] = None
class CommandConfiguredHookHandler(BaseModel):
@@ -913,7 +930,7 @@ class FeedbackUploadParams(BaseModel):
)
classification: str
extra_log_files: Annotated[list[str] | None, Field(alias="extraLogFiles")] = None
include_logs: Annotated[bool, Field(alias="includeLogs")]
include_logs: Annotated[bool | None, Field(alias="includeLogs")] = None
reason: str | None = None
tags: dict[str, Any] | None = None
thread_id: Annotated[str | None, Field(alias="threadId")] = None
@@ -939,7 +956,7 @@ class FileChangeOutputDeltaNotification(BaseModel):
class FileSystemAccessMode(Enum):
read = "read"
write = "write"
none = "none"
deny = "deny"
class PathFileSystemPath(BaseModel):
@@ -1276,6 +1293,17 @@ class InputTextFunctionCallOutputContentItem(BaseModel):
]
class EncryptedContentFunctionCallOutputContentItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
encrypted_content: str
type: Annotated[
Literal["encrypted_content"],
Field(title="EncryptedContentFunctionCallOutputContentItemType"),
]
class FuzzyFileSearchMatchType(Enum):
file = "file"
directory = "directory"
@@ -1335,7 +1363,7 @@ class GetAccountParams(BaseModel):
alias="refreshToken",
description="When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.",
),
] = False
] = None
class GitInfo(BaseModel):
@@ -1425,6 +1453,8 @@ class HookEventName(Enum):
post_compact = "postCompact"
session_start = "sessionStart"
user_prompt_submit = "userPromptSubmit"
subagent_start = "subagentStart"
subagent_stop = "subagentStop"
stop = "stop"
@@ -1506,6 +1536,8 @@ class HooksListParams(BaseModel):
class ImageDetail(Enum):
auto = "auto"
low = "low"
high = "high"
original = "original"
@@ -1742,6 +1774,8 @@ class ManagedHooksRequirements(BaseModel):
pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")]
session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")]
stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="Stop")]
subagent_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStart")]
subagent_stop: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SubagentStop")]
user_prompt_submit: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="UserPromptSubmit")]
managed_dir: Annotated[str | None, Field(alias="managedDir")] = None
windows_managed_dir: Annotated[str | None, Field(alias="windowsManagedDir")] = None
@@ -1835,6 +1869,18 @@ class McpResourceReadParams(BaseModel):
uri: str
class McpServerInfo(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
description: str | None = None
icons: list | None = None
name: str
title: str | None = None
version: str
website_url: Annotated[str | None, Field(alias="websiteUrl")] = None
class McpServerMigration(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -2142,7 +2188,7 @@ class NetworkRequirements(BaseModel):
class NetworkUnixSocketPermission(Enum):
allow = "allow"
none = "none"
deny = "deny"
class NonSteerableTurnKind(Enum):
@@ -2188,6 +2234,32 @@ class PatchChangeKind(
root: AddPatchChangeKind | DeletePatchChangeKind | UpdatePatchChangeKind
class PermissionProfileListParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
cursor: Annotated[
str | None, Field(description="Opaque pagination cursor returned by a previous call.")
] = None
cwd: Annotated[
str | None,
Field(description="Optional working directory to resolve project config layers."),
] = None
limit: Annotated[
int | None, Field(description="Optional page size; defaults to the full result set.", ge=0)
] = None
class PermissionProfileSummary(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
description: Annotated[
str | None, Field(description="Optional user-facing description for display in clients.")
] = None
id: Annotated[str, Field(description="Available permission profile identifier.")]
class Personality(Enum):
none = "none"
friendly = "friendly"
@@ -2332,6 +2404,7 @@ class PluginInterface(BaseModel):
class PluginListMarketplaceKind(Enum):
local = "local"
vertical = "vertical"
workspace_directory = "workspace-directory"
shared_with_me = "shared-with-me"
@@ -3469,6 +3542,20 @@ class SkillsConfigWriteResponse(BaseModel):
effective_enabled: Annotated[bool, Field(alias="effectiveEnabled")]
class SkillsExtraRootsSetParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
extra_roots: Annotated[list[AbsolutePathBuf], Field(alias="extraRoots")]
class SkillsExtraRootsSetResponse(BaseModel):
pass
model_config = ConfigDict(
populate_by_name=True,
)
class SkillsListParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -3624,6 +3711,20 @@ class ThreadCompactStartResponse(BaseModel):
)
class ThreadGoalClearParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadGoalClearResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
cleared: bool
class ThreadGoalClearedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -3631,6 +3732,13 @@ class ThreadGoalClearedNotification(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadGoalGetParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadGoalStatus(Enum):
active = "active"
paused = "paused"
@@ -3749,6 +3857,7 @@ class McpToolCallThreadItem(BaseModel):
error: McpToolCallError | None = None
id: str
mcp_app_resource_uri: Annotated[str | None, Field(alias="mcpAppResourceUri")] = None
plugin_id: Annotated[str | None, Field(alias="pluginId")] = None
result: McpToolCallResult | None = None
server: str
status: McpToolCallStatus
@@ -3922,7 +4031,7 @@ class ThreadReadParams(BaseModel):
alias="includeTurns",
description="When true, include turns and their items from rollout history.",
),
] = False
] = None
thread_id: Annotated[str, Field(alias="threadId")]
@@ -4536,6 +4645,14 @@ class AccountUpdatedNotification(BaseModel):
plan_type: Annotated[PlanType | None, Field(alias="planType")] = None
class AdditionalContextEntry(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
kind: AdditionalContextKind
value: str
class AppConfig(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -4629,6 +4746,24 @@ class ThreadNameSetRequest(BaseModel):
params: ThreadSetNameParams
class ThreadGoalGetRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[Literal["thread/goal/get"], Field(title="Thread/goal/getRequestMethod")]
params: ThreadGoalGetParams
class ThreadGoalClearRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[Literal["thread/goal/clear"], Field(title="Thread/goal/clearRequestMethod")]
params: ThreadGoalClearParams
class ThreadMetadataUpdateRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -4730,6 +4865,17 @@ class SkillsListRequest(BaseModel):
params: SkillsListParams
class SkillsExtraRootsSetRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["skills/extraRoots/set"], Field(title="Skills/extraRoots/setRequestMethod")
]
params: SkillsExtraRootsSetParams
class HooksListRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -4995,6 +5141,17 @@ class ExperimentalFeatureListRequest(BaseModel):
params: ExperimentalFeatureListParams
class PermissionProfileListRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["permissionProfile/list"], Field(title="PermissionProfile/listRequestMethod")
]
params: PermissionProfileListParams
class ExperimentalFeatureEnablementSetRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -5431,16 +5588,22 @@ class ConfigRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
allow_appshots: Annotated[bool | None, Field(alias="allowAppshots")] = None
allow_managed_hooks_only: Annotated[bool | None, Field(alias="allowManagedHooksOnly")] = None
allowed_approval_policies: Annotated[
list[AskForApproval] | None, Field(alias="allowedApprovalPolicies")
] = None
allowed_permissions: Annotated[list[str] | None, Field(alias="allowedPermissions")] = None
allowed_sandbox_modes: Annotated[
list[SandboxMode] | None, Field(alias="allowedSandboxModes")
] = None
allowed_web_search_modes: Annotated[
list[WebSearchMode] | None, Field(alias="allowedWebSearchModes")
] = None
allowed_windows_sandbox_implementations: Annotated[
list[WindowsSandboxSetupMode] | None, Field(alias="allowedWindowsSandboxImplementations")
] = None
computer_use: Annotated[ComputerUseRequirements | None, Field(alias="computerUse")] = None
enforce_residency: Annotated[ResidencyRequirement | None, Field(alias="enforceResidency")] = (
None
)
@@ -5608,13 +5771,19 @@ class InputImageFunctionCallOutputContentItem(BaseModel):
class FunctionCallOutputContentItem(
RootModel[InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem]
RootModel[
InputTextFunctionCallOutputContentItem
| InputImageFunctionCallOutputContentItem
| EncryptedContentFunctionCallOutputContentItem
]
):
model_config = ConfigDict(
populate_by_name=True,
)
root: Annotated[
InputTextFunctionCallOutputContentItem | InputImageFunctionCallOutputContentItem,
InputTextFunctionCallOutputContentItem
| InputImageFunctionCallOutputContentItem
| EncryptedContentFunctionCallOutputContentItem,
Field(
description="Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs."
),
@@ -5767,6 +5936,7 @@ class ListMcpServerStatusParams(BaseModel):
int | None,
Field(description="Optional page size; defaults to a server-defined value.", ge=0),
] = None
thread_id: Annotated[str | None, Field(alias="threadId")] = None
class McpResourceReadResponse(BaseModel):
@@ -5784,6 +5954,7 @@ class McpServerStatus(BaseModel):
name: str
resource_templates: Annotated[list[ResourceTemplate], Field(alias="resourceTemplates")]
resources: list[Resource]
server_info: Annotated[McpServerInfo | None, Field(alias="serverInfo")] = None
tools: dict[str, Tool]
@@ -5817,6 +5988,13 @@ class Model(BaseModel):
] = []
availability_nux: Annotated[ModelAvailabilityNux | None, Field(alias="availabilityNux")] = None
default_reasoning_effort: Annotated[ReasoningEffort, Field(alias="defaultReasoningEffort")]
default_service_tier: Annotated[
str | None,
Field(
alias="defaultServiceTier",
description="Catalog default service tier id for this model, when one is configured.",
),
] = None
description: str
display_name: Annotated[str, Field(alias="displayName")]
hidden: bool
@@ -5859,6 +6037,20 @@ class OverriddenMetadata(BaseModel):
overriding_layer: Annotated[ConfigLayerMetadata, Field(alias="overridingLayer")]
class PermissionProfileListResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
data: list[PermissionProfileSummary]
next_cursor: Annotated[
str | None,
Field(
alias="nextCursor",
description="Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
),
] = None
class PluginSharePrincipal(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6332,6 +6524,30 @@ class ThreadGoal(BaseModel):
updated_at: Annotated[int, Field(alias="updatedAt")]
class ThreadGoalGetResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
goal: ThreadGoal | None = None
class ThreadGoalSetParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
objective: str | None = None
status: ThreadGoalStatus | None = None
thread_id: Annotated[str, Field(alias="threadId")]
token_budget: Annotated[int | None, Field(alias="tokenBudget")] = None
class ThreadGoalSetResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
goal: ThreadGoal
class ThreadGoalUpdatedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6345,6 +6561,7 @@ class UserMessageThreadItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
client_id: Annotated[str | None, Field(alias="clientId")] = None
content: list[UserInput]
id: str
type: Annotated[Literal["userMessage"], Field(title="UserMessageThreadItemType")]
@@ -6536,6 +6753,55 @@ class ThreadListParams(BaseModel):
] = None
class ThreadResumeInitialTurnsPageParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
items_view: Annotated[
TurnItemsView | None,
Field(
alias="itemsView",
description="How much item detail to include for each returned turn; defaults to summary.",
),
] = None
limit: Annotated[int | None, Field(description="Optional turn page size.", ge=0)] = None
sort_direction: Annotated[
SortDirection | None,
Field(
alias="sortDirection",
description="Optional turn pagination direction; defaults to descending.",
),
] = None
class ThreadSettings(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
active_permission_profile: Annotated[
ActivePermissionProfile | None, Field(alias="activePermissionProfile")
] = None
approval_policy: Annotated[AskForApproval, Field(alias="approvalPolicy")]
approvals_reviewer: Annotated[ApprovalsReviewer, Field(alias="approvalsReviewer")]
collaboration_mode: Annotated[CollaborationMode, Field(alias="collaborationMode")]
cwd: AbsolutePathBuf
effort: ReasoningEffort | None = None
model: str
model_provider: Annotated[str, Field(alias="modelProvider")]
personality: Personality | None = None
sandbox_policy: Annotated[SandboxPolicy, Field(alias="sandboxPolicy")]
service_tier: Annotated[str | None, Field(alias="serviceTier")] = None
summary: ReasoningSummary | None = None
class ThreadSettingsUpdatedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
thread_id: Annotated[str, Field(alias="threadId")]
thread_settings: Annotated[ThreadSettings, Field(alias="threadSettings")]
class ThreadStartParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6648,6 +6914,7 @@ class TurnStartParams(BaseModel):
description="Override where approval requests are routed for review on this turn and subsequent turns.",
),
] = None
client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None
cwd: Annotated[
str | None,
Field(description="Override the working directory for this turn and subsequent turns."),
@@ -6696,6 +6963,7 @@ class TurnSteerParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
client_user_message_id: Annotated[str | None, Field(alias="clientUserMessageId")] = None
expected_turn_id: Annotated[
str,
Field(
@@ -6803,6 +7071,15 @@ class ThreadForkRequest(BaseModel):
params: ThreadForkParams
class ThreadGoalSetRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[Literal["thread/goal/set"], Field(title="Thread/goal/setRequestMethod")]
params: ThreadGoalSetParams
class ThreadListRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6891,6 +7168,41 @@ class ConfigValueWriteRequest(BaseModel):
params: ConfigValueWriteParams
class Config(BaseModel):
model_config = ConfigDict(
extra="allow",
populate_by_name=True,
)
analytics: AnalyticsConfig | None = None
approval_policy: AskForApproval | None = None
approvals_reviewer: Annotated[
ApprovalsReviewer | None,
Field(
description="[UNSTABLE] Optional default for where approval requests are routed for review."
),
] = None
compact_prompt: str | None = None
desktop: dict[str, Any] | None = None
developer_instructions: str | None = None
forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None
forced_login_method: ForcedLoginMethod | None = None
instructions: str | None = None
model: str | None = None
model_auto_compact_token_limit: int | None = None
model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | None = None
model_context_window: int | None = None
model_provider: str | None = None
model_reasoning_effort: ReasoningEffort | None = None
model_reasoning_summary: ReasoningSummary | None = None
model_verbosity: Verbosity | None = None
review_model: str | None = None
sandbox_mode: SandboxMode | None = None
sandbox_workspace_write: SandboxWorkspaceWrite | None = None
service_tier: str | None = None
tools: ToolsV2 | None = None
web_search: WebSearchMode | None = None
class ConfigBatchWriteParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6913,6 +7225,15 @@ class ConfigBatchWriteParams(BaseModel):
] = None
class ConfigReadResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
config: Config
layers: list[ConfigLayer] | None = None
origins: dict[str, ConfigLayerMetadata]
class ConfigWriteResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -7115,29 +7436,6 @@ class PluginSummary(BaseModel):
source: PluginSource
class ProfileV2(BaseModel):
model_config = ConfigDict(
extra="allow",
populate_by_name=True,
)
approval_policy: AskForApproval | None = None
approvals_reviewer: Annotated[
ApprovalsReviewer | None,
Field(
description="[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used."
),
] = None
chatgpt_base_url: str | None = None
model: str | None = None
model_provider: str | None = None
model_reasoning_effort: ReasoningEffort | None = None
model_reasoning_summary: ReasoningSummary | None = None
model_verbosity: Verbosity | None = None
service_tier: str | None = None
tools: ToolsV2 | None = None
web_search: WebSearchMode | None = None
class RequestPermissionProfile(BaseModel):
model_config = ConfigDict(
extra="forbid",
@@ -7229,6 +7527,16 @@ class ThreadGoalUpdatedServerNotification(BaseModel):
params: ThreadGoalUpdatedNotification
class ThreadSettingsUpdatedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
method: Annotated[
Literal["thread/settings/updated"], Field(title="Thread/settings/updatedNotificationMethod")
]
params: ThreadSettingsUpdatedNotification
class ThreadTokenUsageUpdatedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -7393,6 +7701,15 @@ class TurnStartedNotification(BaseModel):
turn: Turn
class TurnsPage(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
backwards_cursor: Annotated[str | None, Field(alias="backwardsCursor")] = None
data: list[Turn]
next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
class PluginShareSaveRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -7411,51 +7728,6 @@ class ConfigBatchWriteRequest(BaseModel):
params: ConfigBatchWriteParams
class Config(BaseModel):
model_config = ConfigDict(
extra="allow",
populate_by_name=True,
)
analytics: AnalyticsConfig | None = None
approval_policy: AskForApproval | None = None
approvals_reviewer: Annotated[
ApprovalsReviewer | None,
Field(
description="[UNSTABLE] Optional default for where approval requests are routed for review."
),
] = None
compact_prompt: str | None = None
desktop: dict[str, Any] | None = None
developer_instructions: str | None = None
forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | None = None
forced_login_method: ForcedLoginMethod | None = None
instructions: str | None = None
model: str | None = None
model_auto_compact_token_limit: int | None = None
model_context_window: int | None = None
model_provider: str | None = None
model_reasoning_effort: ReasoningEffort | None = None
model_reasoning_summary: ReasoningSummary | None = None
model_verbosity: Verbosity | None = None
profile: str | None = None
profiles: dict[str, ProfileV2] | None = {}
review_model: str | None = None
sandbox_mode: SandboxMode | None = None
sandbox_workspace_write: SandboxWorkspaceWrite | None = None
service_tier: str | None = None
tools: ToolsV2 | None = None
web_search: WebSearchMode | None = None
class ConfigReadResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
config: Config
layers: list[ConfigLayer] | None = None
origins: dict[str, ConfigLayerMetadata]
class ExternalAgentConfigDetectResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -7892,6 +8164,14 @@ class ThreadRollbackResponse(BaseModel):
]
class ThreadSearchResult(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
snippet: str
thread: Thread
class ThreadStartResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -7960,6 +8240,9 @@ class ClientRequest(
| ThreadArchiveRequest
| ThreadUnsubscribeRequest
| ThreadNameSetRequest
| ThreadGoalSetRequest
| ThreadGoalGetRequest
| ThreadGoalClearRequest
| ThreadMetadataUpdateRequest
| ThreadUnarchiveRequest
| ThreadCompactStartRequest
@@ -7971,6 +8254,7 @@ class ClientRequest(
| ThreadReadRequest
| ThreadInjectItemsRequest
| SkillsListRequest
| SkillsExtraRootsSetRequest
| HooksListRequest
| MarketplaceAddRequest
| MarketplaceRemoveRequest
@@ -8004,6 +8288,7 @@ class ClientRequest(
| ModelListRequest
| ModelProviderCapabilitiesReadRequest
| ExperimentalFeatureListRequest
| PermissionProfileListRequest
| ExperimentalFeatureEnablementSetRequest
| McpServerOauthLoginRequest
| ConfigMcpServerReloadRequest
@@ -8043,6 +8328,9 @@ class ClientRequest(
| ThreadArchiveRequest
| ThreadUnsubscribeRequest
| ThreadNameSetRequest
| ThreadGoalSetRequest
| ThreadGoalGetRequest
| ThreadGoalClearRequest
| ThreadMetadataUpdateRequest
| ThreadUnarchiveRequest
| ThreadCompactStartRequest
@@ -8054,6 +8342,7 @@ class ClientRequest(
| ThreadReadRequest
| ThreadInjectItemsRequest
| SkillsListRequest
| SkillsExtraRootsSetRequest
| HooksListRequest
| MarketplaceAddRequest
| MarketplaceRemoveRequest
@@ -8087,6 +8376,7 @@ class ClientRequest(
| ModelListRequest
| ModelProviderCapabilitiesReadRequest
| ExperimentalFeatureListRequest
| PermissionProfileListRequest
| ExperimentalFeatureEnablementSetRequest
| McpServerOauthLoginRequest
| ConfigMcpServerReloadRequest
@@ -8158,6 +8448,7 @@ class ServerNotification(
| ThreadNameUpdatedServerNotification
| ThreadGoalUpdatedServerNotification
| ThreadGoalClearedServerNotification
| ThreadSettingsUpdatedServerNotification
| ThreadTokenUsageUpdatedServerNotification
| TurnStartedServerNotification
| HookStartedServerNotification
@@ -8227,6 +8518,7 @@ class ServerNotification(
| ThreadNameUpdatedServerNotification
| ThreadGoalUpdatedServerNotification
| ThreadGoalClearedServerNotification
| ThreadSettingsUpdatedServerNotification
| ThreadTokenUsageUpdatedServerNotification
| TurnStartedServerNotification
| HookStartedServerNotification
@@ -265,8 +265,9 @@ def test_schema_normalization_only_flattens_string_literal_oneofs(
"AuthMode",
"InputModality",
"ExperimentalFeatureStage",
"ProcessOutputStream",
"AutoCompactTokenLimitScope",
"CommandExecOutputStream",
"ProcessOutputStream",
]
@@ -357,10 +358,10 @@ def test_source_sdk_template_pins_published_runtime() -> None:
"dependencies": pyproject["project"]["dependencies"],
} == {
"sdk_template_version": "0.0.0-dev",
"runtime_pin": "0.132.0",
"runtime_pin": "0.136.0",
"dependencies": [
"pydantic>=2.12",
"openai-codex-cli-bin==0.132.0",
"openai-codex-cli-bin==0.136.0",
],
}
@@ -435,7 +436,7 @@ def test_runtime_setup_reads_independent_runtime_pin_and_release_tags() -> None:
} == {
"package_name": "openai-codex-cli-bin",
"sdk_template_version": "0.0.0-dev",
"runtime_pin": "0.132.0",
"runtime_pin": "0.136.0",
"normalized_release_version": "0.116.0a1",
"release_tag": "rust-v0.116.0-alpha.1",
}
@@ -594,11 +595,11 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non
tmp_path / "runtime-stage",
"0.116.0a1",
package_archive,
platform_tag="musllinux_1_1_x86_64",
platform_tag="manylinux_2_17_x86_64",
)
pyproject = (staged / "pyproject.toml").read_text()
assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject
assert 'platform-tag = "manylinux_2_17_x86_64"' in pyproject
def test_stage_runtime_release_rejects_incomplete_package_layout(tmp_path: Path) -> None:
@@ -650,7 +651,7 @@ def test_stage_sdk_release_preserves_reviewed_runtime_pin(tmp_path: Path) -> Non
"version": "0.1.0b1",
"dependencies": [
"pydantic>=2.12",
"openai-codex-cli-bin==0.132.0",
"openai-codex-cli-bin==0.136.0",
],
}
assert (
@@ -687,7 +688,7 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None:
)
runtime_stage = script.stage_python_runtime_package(
tmp_path / "runtime-stage",
"0.132.0",
"0.136.0",
package_archive,
)
@@ -700,10 +701,10 @@ def test_sdk_beta_release_can_pin_stable_runtime(tmp_path: Path) -> None:
"sdk_dependencies": sdk_pyproject["project"]["dependencies"],
} == {
"sdk_version": "0.1.0b1",
"runtime_version": "0.132.0",
"runtime_version": "0.136.0",
"sdk_dependencies": [
"pydantic>=2.12",
"openai-codex-cli-bin==0.132.0",
"openai-codex-cli-bin==0.136.0",
],
}
@@ -762,7 +763,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
"--codex-version",
"rust-v0.116.0-alpha.1",
"--platform-tag",
"musllinux_1_1_x86_64",
"manylinux_2_17_x86_64",
]
)
@@ -793,7 +794,7 @@ def test_stage_runtime_stages_package_without_type_generation(tmp_path: Path) ->
script.run_command(args, ops)
assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64:codex-package.tar.gz"]
assert calls == ["stage_runtime:0.116.0a1:manylinux_2_17_x86_64:codex-package.tar.gz"]
def test_default_runtime_is_resolved_from_installed_runtime_package(
+1 -1
View File
@@ -40,7 +40,7 @@ def test_generated_files_are_up_to_date():
# Regenerate contract artifacts via the pinned runtime package, not a local
# app-server binary from the checkout or CI environment.
assert importlib.metadata.version("openai-codex-cli-bin") == "0.132.0"
assert importlib.metadata.version("openai-codex-cli-bin") == "0.136.0"
env = os.environ.copy()
env.pop("CODEX_EXEC_PATH", None)
python_bin = str(Path(sys.executable).parent)
+9 -9
View File
@@ -7,7 +7,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for
exclude-newer-span = "P7D"
[options.exclude-newer-package]
openai-codex-cli-bin = "2026-05-20T21:00:00Z"
openai-codex-cli-bin = "2026-06-01T20:00:00Z"
[[package]]
name = "annotated-types"
@@ -299,7 +299,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "==0.31.2" },
{ name = "openai-codex-cli-bin", specifier = "==0.132.0" },
{ name = "openai-codex-cli-bin", specifier = "==0.136.0" },
{ name = "pydantic", specifier = ">=2.12" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.8" },
@@ -308,15 +308,15 @@ provides-extras = ["dev"]
[[package]]
name = "openai-codex-cli-bin"
version = "0.132.0"
version = "0.136.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/a1/b92b7a1b73a83785d2e1dcd0faecd1b7f886a38cf02a30abe1c35f42f0f7/openai_codex_cli_bin-0.132.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1c22b51dbd679413f84f00b9d8fd4e5cf8a1c0d1c7cc8c42bcb3f9f1b33e2334", size = 89403211, upload-time = "2026-05-20T02:37:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/5f/68/163272e582de55a7f460e2329281267908d75d0fbcbbbb2c6749a6329e6b/openai_codex_cli_bin-0.132.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56217495e6635c8a5d96df820cc0da5f46cd9b6ec6f3a5f67f1607d69ef74256", size = 79058685, upload-time = "2026-05-20T02:37:27.165Z" },
{ url = "https://files.pythonhosted.org/packages/0b/18/a60c6b137e7cd3959cae16ba757f57ca5702979b0ea107a21f516ba15d98/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:09642e7578a3078bfccc82af4077b085d42b0022b529e4b5c645e0a0af3397a4", size = 78689038, upload-time = "2026-05-20T02:37:31.548Z" },
{ url = "https://files.pythonhosted.org/packages/f8/eb/1b184307a67c1006d59b61636bcfcea73a89aa95271f6516ed28dce554ca/openai_codex_cli_bin-0.132.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:85aec095f9d144d7a2d1aff39fce77b7240f42014580c35801ba74b9317aa5f7", size = 85528820, upload-time = "2026-05-20T02:37:36.559Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e8/1b823a8bf7b96d1513905ad79b16a146d797f81a19a6bc350a2f95a16661/openai_codex_cli_bin-0.132.0-py3-none-win_amd64.whl", hash = "sha256:3cb5c90c55baa39bd5ddc890d2068d3e1322a57a54d1d0e623819009a205c7f5", size = 86916218, upload-time = "2026-05-20T02:37:41.886Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e6/bb8634bd4f3adaea299c95d7b03105ac417e32dd6d8bc2af5dda141d6f28/openai_codex_cli_bin-0.132.0-py3-none-win_arm64.whl", hash = "sha256:74ef93d3deef7cb83c71d19fc667defe749cdab337ec331f59a23511561b6f6a", size = 79892931, upload-time = "2026-05-20T02:37:46.828Z" },
{ url = "https://files.pythonhosted.org/packages/45/2e/09764338fcb104ebcb1ebf2490d71ab4b8339b5d6f8bdce19480adad5a99/openai_codex_cli_bin-0.136.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9ded24a9977221f67173f49b8c3ba3a944787b7323dbd664dd8e8ae1a5efc0aa", size = 94036262, upload-time = "2026-06-01T19:28:13.291Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/10dea458f8ead97b6449a1c05b65883ffe28724204c05e6169439576ee48/openai_codex_cli_bin-0.136.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8d194688c1afe87bf85705528cbcbdb762cb378d8c58c64162402babbd795436", size = 83713829, upload-time = "2026-06-01T19:28:17.39Z" },
{ url = "https://files.pythonhosted.org/packages/7d/83/21402a805f3312196a4f4bef0b6a45d3dee301e3db3ddeee117b4b1e612b/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:bdbdfeeac5bb2e744bd810cb60c55313c280d0f6ff9e93699431bcfc2040c9c2", size = 82981863, upload-time = "2026-06-01T19:28:21.246Z" },
{ url = "https://files.pythonhosted.org/packages/33/79/1617e008381345391def39254404c0248a425739bd13c95d9fbe3e63d16f/openai_codex_cli_bin-0.136.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:37b353435f354c6d1bcc32e3b83e40ef9d1ef7294c8adedd229fbaedc82a6f74", size = 90187224, upload-time = "2026-06-01T19:28:24.89Z" },
{ url = "https://files.pythonhosted.org/packages/f4/bc/a591d4464f999e0fab6abb5ba369463eee99a82e7c298e2cff257d80b849/openai_codex_cli_bin-0.136.0-py3-none-win_amd64.whl", hash = "sha256:c91ffbb1d903970e74b65958f7d1fa548fac8abdfe9b3a098f3da7f47dbb2575", size = 91137642, upload-time = "2026-06-01T19:28:28.512Z" },
{ url = "https://files.pythonhosted.org/packages/89/f3/e8ccd644e7a87a25229022088abf4d724d3d608fcda7123f9a11515409ca/openai_codex_cli_bin-0.136.0-py3-none-win_arm64.whl", hash = "sha256:9073f7283c47ad3e585e23598ebbef554e85d280cabb637b6cb9a078c5e7086a", size = 83623904, upload-time = "2026-06-01T19:28:32.734Z" },
]
[[package]]