Load cloud config for sandbox and remote exec-server commands (#57270)

## Why

Sandbox invocations without an explicit permissions profile and remote `exec-server` connections using ChatGPT auth need access to cloud-defined model providers and managed requirements. Cloud bootstrap requests must also respect local application network policy.

## What changed

- Bootstrap sandbox cloud config whenever managed config is included, even without an explicit profile. Skip bootstrap when `--sandbox-state-json` supplies the sandbox state.
- Load cloud config for remote executors beyond workload identity, while skipping forwarders and executors using stdin credentials, AWS SigV4, or agent identity auth. Honor `CODEX_API_KEY` selection so API key connections skip ChatGPT cloud config.
- Apply local application network policy during cloud bootstrap and activate effective application network policy before exec-server clients are created.

## Testing

Add coverage for cloud-defined providers in sandbox and remote exec-server invocations, API key bypass, local policy blocking bundle requests, cloud policy blocking remote registration, and bootstrap exclusions.

GitOrigin-RevId: e8bd6210751e96161f575ade309ebf41bf10f8b5
This commit is contained in:
Abhinav
2026-10-26 21:31:28 +00:00
committed by copyberry
parent 0818b91867
commit 51e3f94b2b
7 changed files with 273 additions and 79 deletions
@@ -1,3 +1,4 @@
use codex_app_server::in_process::EmbeddedNetworkPolicy;
use codex_cloud_config::chatgpt_config_bundle_loader_for_storage;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
@@ -15,7 +16,7 @@ pub(super) async fn bootstrap_cloud_config_bundle(
resolve_codex_home: impl FnOnce() -> std::io::Result<AbsolutePathBuf>,
strict_config: bool,
) -> anyhow::Result<CloudConfigBundleLoader> {
if options.permissions_profile.is_none()
if options.sandbox_state.sandbox_state_json.is_some()
|| !matches!(
options.managed_requirements_mode,
ManagedRequirementsMode::Include
@@ -29,6 +30,7 @@ pub(super) async fn bootstrap_cloud_config_bundle(
Some(cwd) => AbsolutePathBuf::relative_to_current_dir(cwd)?,
None => AbsolutePathBuf::current_dir()?,
};
let network_policy = EmbeddedNetworkPolicy::load(&options.loader_overrides).await;
let bootstrap_config = load_config_toml_with_layer_stack(
codex_home.as_path(),
Some(&cwd),
@@ -41,7 +43,10 @@ pub(super) async fn bootstrap_cloud_config_bundle(
)
.await?;
Ok(chatgpt_config_bundle_loader_for_storage(
bootstrap_auth_config(codex_home.as_path(), &bootstrap_config)?,
network_policy.bind_bootstrap_auth(bootstrap_auth_config(
codex_home.as_path(),
&bootstrap_config,
)?),
/*enable_codex_api_key_env*/ false,
)
.await?)
@@ -1,3 +1,4 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
@@ -36,7 +37,7 @@ enabled = true
"#;
#[tokio::test]
async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend() -> Result<()> {
async fn sandbox_fetches_managed_profile_only_when_local_policy_allows_backend() -> Result<()> {
let server = MockServer::start().await;
let expected_requirements = json!([{
"id": "req-managed-cloud",
@@ -74,20 +75,45 @@ async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend(
AuthCredentialsStoreMode::File,
)?;
let options = DebugSandboxConfigOptions {
let mut options = DebugSandboxConfigOptions {
sandbox_state: Default::default(),
permissions_profile: Some("managed-cloud".to_string()),
permissions_profile: None,
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 bootstrap = async |options: &DebugSandboxConfigOptions| {
bootstrap_cloud_config_bundle(
&[],
options,
|| AbsolutePathBuf::from_absolute_path(codex_home.path()),
/*strict_config*/ false,
)
.await
};
let requirements = codex_home.path().join("requirements.toml");
std::fs::write(&requirements, "[application.network]\nenabled = true\n")?;
options.loader_overrides.system_requirements_path = Some(requirements);
let denied = bootstrap(&options)
.await?
.get()
.await
.expect_err("local policy denies the bundle endpoint");
assert_eq!(
denied.to_string(),
"destination denied by application network policy"
);
assert!(
server
.received_requests()
.await
.context("mock request log")?
.is_empty()
);
options.permissions_profile = Some("managed-cloud".to_string());
options.loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let cloud_config_bundle = bootstrap(&options).await?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
@@ -135,3 +161,25 @@ async fn debug_sandbox_bootstraps_cloud_managed_permission_profile_from_backend(
Ok(())
}
#[tokio::test]
async fn sandbox_state_skips_cloud_bootstrap() -> Result<()> {
let options = DebugSandboxConfigOptions {
sandbox_state: crate::SandboxStateArgs {
sandbox_state_json: Some("{}".into()),
..Default::default()
},
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
};
bootstrap_cloud_config_bundle(
&[],
&options,
|| panic!("supplied state must skip CODEX_HOME resolution"),
/*strict_config*/ false,
)
.await?;
Ok(())
}
@@ -72,6 +72,20 @@ fn exec_server_defaults_preserve_local_and_noise_modes() {
}
}
#[test]
fn remote_forwarder_skips_cloud_config() {
let command = exec_server_from_args(&[
"forward",
"--connect",
"ws://localhost:8765",
"--remote",
"https://registry.example.com",
"--environment-id",
"env-1",
]);
assert!(!command.should_load_cloud_config());
}
#[test]
fn exec_server_parses_direct_aws_options() {
for (options, expected) in [
@@ -107,6 +121,7 @@ fn exec_server_parses_direct_aws_options() {
assert_eq!(command.environment_id.as_deref(), Some("env-1"));
assert_eq!(command.remote_transport, ExecServerRemoteTransport::Direct);
assert!(command.aws_sigv4);
assert!(!command.should_load_cloud_config());
assert_eq!(
(
command.aws_profile.as_deref(),
+26 -19
View File
@@ -5,6 +5,7 @@
use std::sync::Arc;
use clap::Parser;
use codex_app_server::in_process::EmbeddedNetworkPolicy;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::chatgpt_config_bundle_loader_for_storage;
use codex_config::LoaderOverrides;
@@ -19,7 +20,6 @@ use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::is_workload_identity_selected;
use codex_login::read_codex_access_token_from_env;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -176,6 +176,7 @@ impl ExecServerCommand {
) -> anyhow::Result<()> {
let strict_config = self.strict_config;
self.validate_remote_transport()?;
let enable_cloud_config = self.should_load_cloud_config();
let websocket_auth = self.websocket_auth.try_into_settings()?;
if websocket_auth.config.is_some() && (self.remote.is_some() || self.command.is_some()) {
anyhow::bail!("WebSocket listener auth cannot be used with --remote or forward");
@@ -203,12 +204,9 @@ impl ExecServerCommand {
let environment_id = self.environment_id.take().ok_or_else(|| {
anyhow::anyhow!("--environment-id is required when --remote is set")
})?;
let config = load_exec_server_config(
root_config_overrides,
strict_config,
/*enable_workload_identity*/ !self.executor_credential_stdin,
)
.await?;
let config =
load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config)
.await?;
let direct_transport = self.remote_transport == ExecServerRemoteTransport::Direct;
// Configured exporters may contain account-wide credentials or client keys.
// The scoped executor must not initialize those exporters.
@@ -309,12 +307,9 @@ impl ExecServerCommand {
}
result
} else {
let config_result = load_exec_server_config(
root_config_overrides,
strict_config,
/*enable_workload_identity*/ false,
)
.await;
let config_result =
load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config)
.await;
let config = if strict_config {
Some(config_result?)
} else {
@@ -353,6 +348,14 @@ impl ExecServerCommand {
}
}
pub(super) fn should_load_cloud_config(&self) -> bool {
self.remote.is_some()
&& self.command.is_none()
&& !self.executor_credential_stdin
&& !self.aws_sigv4
&& !self.use_agent_identity_auth
}
pub(super) fn validate_remote_transport(&self) -> anyhow::Result<()> {
match (self.remote_transport, self.aws_sigv4) {
(ExecServerRemoteTransport::Noise, true) => {
@@ -482,7 +485,7 @@ pub(super) fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()>
async fn load_exec_server_config(
root_config_overrides: &CliConfigOverrides,
strict_config: bool,
enable_workload_identity: bool,
enable_cloud_config: bool,
) -> anyhow::Result<codex_core::config::Config> {
let cli_kv_overrides = root_config_overrides
.parse_overrides()
@@ -491,15 +494,17 @@ async fn load_exec_server_config(
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.strict_config(strict_config);
if enable_workload_identity && is_workload_identity_selected() {
if enable_cloud_config {
let codex_home = find_codex_home()?;
let loader_overrides = LoaderOverrides::default();
let network_policy = EmbeddedNetworkPolicy::load(&loader_overrides).await;
let bootstrap_cwd = AbsolutePathBuf::current_dir()?;
let bootstrap_config = load_config_toml_with_layer_stack(
&codex_home,
Some(&bootstrap_cwd),
bootstrap_cli_overrides,
ConfigLoadOptions {
loader_overrides: LoaderOverrides::default(),
loader_overrides,
strict_config,
cloud_config_bundle: Default::default(),
},
@@ -507,13 +512,15 @@ async fn load_exec_server_config(
.await?;
let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?;
let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage(
bootstrap_auth_config,
/*enable_codex_api_key_env*/ false,
network_policy.bind_bootstrap_auth(bootstrap_auth_config),
/*enable_codex_api_key_env*/ true,
)
.await?;
builder = builder.cloud_config_bundle(cloud_config_bundle);
}
Ok(builder.build().await?)
let mut config = builder.build().await?;
EmbeddedNetworkPolicy::default().activate(&mut config);
Ok(config)
}
async fn load_exec_server_remote_auth(
+107 -8
View File
@@ -46,7 +46,7 @@ struct CloudManagedConfigFixture {
}
impl CloudManagedConfigFixture {
async fn new() -> Result<Option<Self>> {
async fn for_backend() -> Result<Option<Self>> {
let server = MockServer::start().await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let codex_home = TempDir::new()?;
@@ -84,6 +84,19 @@ impl CloudManagedConfigFixture {
)?;
let mcp_url = format!("{}/mcp", server.uri());
Ok(Some(Self {
server,
codex_home,
user_config,
mcp_url,
}))
}
async fn new() -> Result<Option<Self>> {
let Some(fixture) = Self::for_backend().await? else {
return Ok(None);
};
let mcp_url = &fixture.mcp_url;
let managed_config = format!(
"mcp_oauth_credentials_store = \"file\"\n\n\
[mcp_servers.{MANAGED_SERVER_NAME}]\n\
@@ -110,15 +123,10 @@ impl CloudManagedConfigFixture {
},
})))
.expect(1)
.mount(&server)
.mount(&fixture.server)
.await;
Ok(Some(Self {
server,
codex_home,
user_config,
mcp_url,
}))
Ok(Some(fixture))
}
fn command(&self, args: &[&str]) -> Result<Command> {
@@ -455,6 +463,7 @@ async fn mock_startup_bundle(server: &MockServer, status: u16, config: &str, req
};
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/bundle"))
.and(header("authorization", "Bearer chatgpt-token"))
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
"config_toml": layer(config), "requirements_toml": layer(requirements),
})))
@@ -579,3 +588,93 @@ async fn startup_config_sign_out_reloads_policy_without_starting_runtime() -> Re
}
Ok(())
}
const BUNDLE: &str = "/backend-api/wham/config/bundle";
const REGISTER: &str = "/cloud/environment/test/register";
const REGISTRATION_REACHED: &str = "registration reached";
#[tokio::test]
async fn remote_exec_server_loads_cloud_provider_with_chatgpt_auth() -> Result<()> {
check_registration(
"forced_login_method = 'chatgpt'\nmodel_provider = 'cloud-provider'",
Some((
"[model_providers.cloud-provider]\nname = 'Cloud provider'\nwire_api = 'responses'",
"",
)),
"Bearer chatgpt-token",
REGISTRATION_REACHED,
&[BUNDLE, REGISTER],
)
.await
}
#[tokio::test]
async fn remote_exec_server_api_key_skips_chatgpt_cloud_config() -> Result<()> {
check_registration(
"",
/*bundle*/ None,
"Bearer test-api-key",
REGISTRATION_REACHED,
&[REGISTER],
)
.await
}
#[tokio::test]
async fn remote_exec_server_cloud_policy_denies_registration() -> Result<()> {
check_registration(
"forced_login_method = 'chatgpt'",
Some(("", "[application.network]\nenabled = true\n[application.network.domains]\n'gateway.example.com' = 'allow'")),
"Bearer chatgpt-token",
"destination denied by application network policy",
&[BUNDLE],
)
.await
}
async fn check_registration(
config: &str,
bundle: Option<(&str, &str)>,
registration_auth: &str,
expected_error: &str,
expected_paths: &[&str],
) -> Result<()> {
let fixture = CloudManagedConfigFixture::for_backend()
.await?
.context("remote registration test requires isolated mock credentials")?;
let server = &fixture.server;
if let Some((config, requirements)) = bundle {
mock_startup_bundle(server, /*status*/ 200, config, requirements).await;
}
Mock::given(method("POST"))
.and(path(REGISTER))
.and(header("authorization", registration_auth))
.respond_with(ResponseTemplate::new(400).set_body_string(REGISTRATION_REACHED))
.mount(server)
.await;
std::fs::write(
fixture.codex_home.path().join("config.toml"),
format!("{}{config}\n", fixture.user_config),
)?;
let mut command = fixture.command(&[
"exec-server",
"--remote",
&server.uri(),
"--environment-id",
"test",
])?;
command.env("CODEX_API_KEY", "test-api-key");
let output = timeout(Duration::from_secs(30), command.output()).await??;
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success() && stderr.contains(expected_error),
"{stderr}"
);
let requests = server
.received_requests()
.await
.context("mock request log")?;
let paths: Vec<_> = requests.iter().map(|request| request.url.path()).collect();
assert_eq!(paths, expected_paths);
Ok(())
}
@@ -27,7 +27,9 @@ async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Re
)?;
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("old-token").account_id("account"),
ChatGptAuthFixture::new("old-token")
.account_id("account")
.chatgpt_user_id("user"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(path("/refresh"))
@@ -94,7 +96,9 @@ async fn noise_executor_refreshes_on_401_and_reloads_rotated_credentials() -> Re
// Update storage while the executor is alive, then force a new registration.
write_chatgpt_auth(
home.path(),
ChatGptAuthFixture::new("rotated-token").account_id("account"),
ChatGptAuthFixture::new("rotated-token")
.account_id("account")
.chatgpt_user_id("user"),
AuthCredentialsStoreMode::File,
)?;
websocket.close(None).await?;
+54 -38
View File
@@ -34,14 +34,53 @@ enabled = true
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Result<()> {
let requirements = json!({"enterprise_managed": [{
"id": "req-managed-cloud", "name": "Managed permissions",
"contents": CLOUD_MANAGED_PERMISSION_PROFILE_REQUIREMENTS,
}]});
let Some(codex_home) = run_cloud_sandbox(
&["-P", "managed-cloud", "--include-managed-config"],
json!({"requirements_toml": requirements}),
)
.await?
else {
return Ok(());
};
let cache: Value = serde_json::from_slice(&std::fs::read(
codex_home.path().join("cloud-config-bundle-cache.json"),
)?)?;
let payload = &cache["signed_payload"];
assert_eq!(payload["chatgpt_user_id"], "user-123");
assert_eq!(payload["account_id"], "workspace-123");
assert_eq!(payload["bundle"]["requirements_toml"], requirements);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sandbox_loads_cloud_provider_for_cua_style_invocation() -> Result<()> {
run_cloud_sandbox(
&[
"-c",
"model_provider='cloud-provider'",
"-c",
"default_permissions='cua'",
"-c",
"permissions.cua={extends=':workspace',network={enabled=true}}",
"-c",
"approval_policy='never'",
],
json!({"config_toml": {"enterprise_managed": [{
"id": "cloud-provider", "name": "Cloud provider",
"contents": "[model_providers.cloud-provider]\nname = 'Cloud provider'\nwire_api = 'responses'",
}]}}),
)
.await?;
Ok(())
}
async fn run_cloud_sandbox(args: &[&str], bundle: Value) -> Result<Option<TempDir>> {
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"),
@@ -73,7 +112,7 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu
eprintln!(
"skipping cloud-managed sandbox subprocess: host-managed authentication or backend routing prevents isolated mock credentials"
);
return Ok(());
return Ok(None);
}
write_chatgpt_auth(
@@ -89,11 +128,7 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu
.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(),
},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(bundle))
.expect(1)
.mount(&server)
.await;
@@ -107,15 +142,14 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu
.env("no_proxy", "127.0.0.1,localhost")
.env_remove("CODEX_ACCESS_TOKEN")
.env_remove("OPENAI_API_KEY")
.env_remove("OPENAI_FEDERATION_RULE_ID")
.env_remove("OPENAI_IDENTITY_TOKEN_FILE")
.env_remove("OPENAI_WORKLOAD_IDENTITY_CONTEXT")
.args(["-c", "cli_auth_credentials_store=\"file\""])
.args(["-c", chatgpt_base_url_override.as_str()])
.args([
"sandbox",
"-P",
"managed-cloud",
"--include-managed-config",
"--",
])
.arg("sandbox")
.args(args)
.arg("--")
.arg(&codex)
.arg("--version")
.output()?;
@@ -145,24 +179,6 @@ async fn sandbox_fetches_and_enforces_cloud_managed_permission_profile() -> Resu
);
}
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(())
Ok(Some(codex_home))
}