Load cloud-managed profiles for codex sandbox (#35685)

## What changed

- Bootstrap the cloud configuration bundle when `codex sandbox` receives an
  explicit permission profile together with `--include-managed-config`.
- Pass the resulting managed requirements through sandbox configuration loading
  so the requested cloud-managed permission profile is enforced.
- Keep the default path from loading cloud-managed profiles when managed
  configuration is not requested.

## Testing

- Add unit and subprocess coverage for fetching, caching, and enforcing a
  cloud-managed permission profile.

GitOrigin-RevId: dfe637af5895496ae88b8128ca2f5ec29341ad06
This commit is contained in:
viyatb-oai
2026-07-27 22:41:04 +00:00
committed by copyberry
parent 4d1f66bf81
commit fb6aad9ae3
6 changed files with 506 additions and 0 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2326,6 +2326,7 @@ dependencies = [
"codex-app-server-test-client",
"codex-arg0",
"codex-chatgpt",
"codex-cloud-config",
"codex-cloud-tasks",
"codex-config",
"codex-core",

View File

@@ -28,6 +28,7 @@ codex-app-server-test-client = { workspace = true }
codex-arg0 = { workspace = true }
codex-api = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-config = { workspace = true }
codex-cloud-tasks = { path = "../cloud-tasks" }
codex-utils-cli = { workspace = true }
codex-config = { workspace = true }

View File

@@ -1,3 +1,4 @@
mod cloud_config;
#[cfg(target_os = "macos")]
mod pid_tracker;
#[cfg(target_os = "macos")]
@@ -7,11 +8,13 @@ use std::path::PathBuf;
use std::process::Stdio;
use anyhow::Context as _;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::find_codex_home;
use codex_core::exec_env::create_env;
#[cfg(target_os = "macos")]
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
@@ -550,11 +553,20 @@ async fn load_debug_sandbox_config(
options: DebugSandboxConfigOptions,
strict_config: bool,
) -> anyhow::Result<Config> {
let cloud_config_bundle = cloud_config::bootstrap_cloud_config_bundle(
&cli_overrides,
&options,
find_codex_home,
strict_config,
)
.await?;
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
/*codex_home*/ None,
cloud_config_bundle,
strict_config,
)
.await
@@ -565,6 +577,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
cloud_config_bundle: CloudConfigBundleLoader,
strict_config: bool,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
@@ -598,6 +611,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_home.clone(),
managed_requirements_mode,
loader_overrides.clone(),
cloud_config_bundle.clone(),
strict_config,
)
.await?;
@@ -617,6 +631,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_home,
managed_requirements_mode,
loader_overrides,
cloud_config_bundle,
strict_config,
)
.await
@@ -629,11 +644,13 @@ async fn build_debug_sandbox_config_with_loader_overrides(
codex_home: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
mut loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
strict_config: bool,
) -> std::io::Result<Config> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides)
.cloud_config_bundle(cloud_config_bundle)
.strict_config(strict_config);
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
loader_overrides.ignore_managed_requirements = true;
@@ -662,9 +679,24 @@ fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)])
#[cfg(test)]
mod tests {
use super::*;
use codex_config::ConfigRequirementsToml;
use codex_config::test_support::CloudConfigBundleFixture;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#"
default_permissions = "managed-cloud"
[allowed_permission_profiles]
managed-cloud = true
[permissions.managed-cloud]
extends = ":workspace"
[permissions.managed-cloud.network]
enabled = true
"#;
async fn build_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
@@ -678,6 +710,7 @@ mod tests {
codex_home,
managed_requirements_mode,
LoaderOverrides::default(),
CloudConfigBundleLoader::default(),
strict_config,
)
.await
@@ -761,6 +794,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -804,6 +838,7 @@ mod tests {
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
loader_overrides.clone(),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -830,6 +865,7 @@ mod tests {
loader_overrides,
},
Some(codex_home_path),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -888,6 +924,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -947,6 +984,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -975,6 +1013,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -996,6 +1035,92 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_cloud_managed_permission_profile() -> anyhow::Result<()>
{
let codex_home = TempDir::new()?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
sandbox_state: Default::default(),
permissions_profile: Some("managed-cloud".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
},
Some(codex_home.path().to_path_buf()),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
),
/*strict_config*/ false,
)
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-cloud".to_string()),
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled,
);
assert_eq!(
config.config_layer_stack.requirements_toml(),
&toml::from_str::<ConfigRequirementsToml>(
CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
)?,
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_ignores_cloud_managed_permission_profiles_by_default()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
sandbox_state: Default::default(),
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
},
Some(codex_home.path().to_path_buf()),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
),
/*strict_config*/ false,
)
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(":workspace".to_string()),
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
config.config_layer_stack.requirements_toml(),
&ConfigRequirementsToml::default(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -1015,6 +1140,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;
@@ -1055,6 +1181,7 @@ mod tests {
loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
CloudConfigBundleLoader::default(),
/*strict_config*/ false,
)
.await?;

View File

@@ -0,0 +1,72 @@
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_bootstrap_auth_route_config;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
use super::DebugSandboxConfigOptions;
use super::ManagedRequirementsMode;
pub(super) async fn bootstrap_cloud_config_bundle(
cli_overrides: &[(String, TomlValue)],
options: &DebugSandboxConfigOptions,
resolve_codex_home: impl FnOnce() -> std::io::Result<AbsolutePathBuf>,
strict_config: bool,
) -> anyhow::Result<CloudConfigBundleLoader> {
if options.permissions_profile.is_none()
|| !matches!(
options.managed_requirements_mode,
ManagedRequirementsMode::Include
)
{
return Ok(CloudConfigBundleLoader::default());
}
let codex_home = resolve_codex_home()?;
let cwd = match options.cwd.as_deref() {
Some(cwd) => AbsolutePathBuf::relative_to_current_dir(cwd)?,
None => AbsolutePathBuf::current_dir()?,
};
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home.as_path(),
Some(&cwd),
cli_overrides.to_vec(),
ConfigLoadOptions {
loader_overrides: options.loader_overrides.clone(),
strict_config,
cloud_config_bundle: CloudConfigBundleLoader::default(),
},
)
.await?;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let auth_route_config = resolve_bootstrap_auth_route_config(
bootstrap_config_toml,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.as_ref(),
)?;
Ok(cloud_config_bundle_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
bootstrap_config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()),
auth_route_config,
)
.await)
}
#[cfg(test)]
#[path = "cloud_config_tests.rs"]
mod tests;

View File

@@ -0,0 +1,137 @@
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_config::ConfigRequirementsToml;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::super::DebugSandboxConfigOptions;
use super::super::ManagedRequirementsMode;
use super::super::load_debug_sandbox_config_with_codex_home;
use super::bootstrap_cloud_config_bundle;
const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#"
default_permissions = "managed-cloud"
[allowed_permission_profiles]
managed-cloud = true
[permissions.managed-cloud]
extends = ":workspace"
[permissions.managed-cloud.network]
enabled = true
"#;
#[tokio::test]
async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend() -> Result<()> {
let server = MockServer::start().await;
let expected_requirements = json!([{
"id": "req-managed-cloud",
"name": "Managed permissions",
"contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
}]);
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/bundle"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "workspace-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements_toml": {
"enterprise_managed": expected_requirements.clone(),
},
})))
.expect(1)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n",
server.uri(),
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("workspace-123")
.chatgpt_account_id("workspace-123")
.chatgpt_user_id("user-123")
.plan_type("enterprise"),
AuthCredentialsStoreMode::File,
)?;
let options = DebugSandboxConfigOptions {
sandbox_state: Default::default(),
permissions_profile: Some("managed-cloud".to_string()),
cwd: Some(codex_home.path().to_path_buf()),
managed_requirements_mode: ManagedRequirementsMode::Include,
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
};
let cloud_config_bundle = bootstrap_cloud_config_bundle(
&[],
&options,
|| AbsolutePathBuf::from_absolute_path(codex_home.path()),
/*strict_config*/ false,
)
.await?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
options,
Some(codex_home.path().to_path_buf()),
cloud_config_bundle,
/*strict_config*/ false,
)
.await?;
assert_eq!(
config
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some("managed-cloud".to_string()),
);
assert_eq!(
config.permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Enabled,
);
assert_eq!(
config.config_layer_stack.requirements_toml(),
&toml::from_str::<ConfigRequirementsToml>(CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,)?,
);
let cache: Value = serde_json::from_slice(&std::fs::read(
codex_home.path().join("cloud-config-bundle-cache.json"),
)?)?;
assert_eq!(
json!({
"chatgpt_user_id": cache["signed_payload"]["chatgpt_user_id"],
"account_id": cache["signed_payload"]["account_id"],
"requirements_toml": cache["signed_payload"]["bundle"]["requirements_toml"],
}),
json!({
"chatgpt_user_id": "user-123",
"account_id": "workspace-123",
"requirements_toml": {
"enterprise_managed": expected_requirements,
},
}),
);
server.verify().await;
Ok(())
}

View File

@@ -0,0 +1,168 @@
use std::process::Command;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_config::ConfigLoadOptions;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS: &str = r#"
default_permissions = "managed-cloud"
[allowed_permission_profiles]
managed-cloud = true
[permissions.managed-cloud]
extends = ":workspace"
[permissions.managed-cloud.network]
enabled = true
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Result<()> {
let server = MockServer::start().await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let expected_requirements = json!([{
"id": "req-managed-cloud",
"name": "Managed permissions",
"contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
}]);
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{chatgpt_base_url}\"\n",
),
)?;
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home.path(),
Some(&AbsolutePathBuf::from_absolute_path(codex_home.path())?),
vec![
(
"cli_auth_credentials_store".to_string(),
toml::Value::String("file".to_string()),
),
(
"chatgpt_base_url".to_string(),
toml::Value::String(chatgpt_base_url.clone()),
),
],
ConfigLoadOptions::default(),
)
.await?;
if bootstrap_config.config_toml.cli_auth_credentials_store
!= Some(AuthCredentialsStoreMode::File)
|| bootstrap_config.config_toml.chatgpt_base_url.as_deref()
!= Some(chatgpt_base_url.as_str())
{
eprintln!(
"skipping cloud-managed sandbox subprocess: host-managed authentication or backend routing prevents isolated mock credentials"
);
return Ok(());
}
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("workspace-123")
.chatgpt_account_id("workspace-123")
.chatgpt_user_id("user-123")
.plan_type("enterprise"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/bundle"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "workspace-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"requirements_toml": {
"enterprise_managed": expected_requirements.clone(),
},
})))
.expect(1)
.mount(&server)
.await;
let codex = codex_utils_cargo_bin::cargo_bin("codex")?;
let chatgpt_base_url_override = format!("chatgpt_base_url=\"{chatgpt_base_url}\"");
let output = Command::new(&codex)
.current_dir(codex_home.path())
.env("CODEX_HOME", codex_home.path())
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost")
.env_remove("CODEX_ACCESS_TOKEN")
.env_remove("OPENAI_API_KEY")
.args(["-c", "cli_auth_credentials_store=\"file\""])
.args(["-c", chatgpt_base_url_override.as_str()])
.args([
"sandbox",
"-P",
"managed-cloud",
"--include-managed-config",
"--",
])
.arg(&codex)
.arg("--version")
.output()?;
let cloud_bundle_request_paths: Vec<_> = server
.received_requests()
.await
.context("failed to read mock cloud configuration requests")?
.into_iter()
.map(|request| request.url.path().to_string())
.collect();
let stderr = String::from_utf8_lossy(&output.stderr);
let nested_macos_sandbox_unavailable = cfg!(target_os = "macos")
&& output.status.code() == Some(71)
&& stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted");
assert!(
output.status.success() || nested_macos_sandbox_unavailable,
"cloud-managed sandbox profile was not enforced: status={:?}; stdout={}; stderr={}; cloud bundle requests={cloud_bundle_request_paths:?}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
stderr,
);
if !nested_macos_sandbox_unavailable {
assert!(
String::from_utf8(output.stdout)?.starts_with("codex"),
"expected the sandboxed Codex version command to run",
);
}
let cache: Value = serde_json::from_slice(&std::fs::read(
codex_home.path().join("cloud-config-bundle-cache.json"),
)?)?;
assert_eq!(
json!({
"chatgpt_user_id": cache["signed_payload"]["chatgpt_user_id"],
"account_id": cache["signed_payload"]["account_id"],
"requirements_toml": cache["signed_payload"]["bundle"]["requirements_toml"],
}),
json!({
"chatgpt_user_id": "user-123",
"account_id": "workspace-123",
"requirements_toml": {
"enterprise_managed": expected_requirements,
},
}),
);
server.verify().await;
Ok(())
}