Preserve bootstrap discovery across authentication changes (#49432)

## Why

Embedded app-server configuration discovery must remain available after login and workspace changes, while content access granted to the previous account must be revoked.

## What changed

Give `AuthRouteConfig` a separate application network policy and bind embedded bootstrap authentication to the effective application policy. Use that policy for account-owned content clients and invalidate it when the authenticated owner changes, preserving the local, endpoint-restricted bootstrap policy.

## Testing

Add a regression test covering bootstrap discovery across login, workspace changes, and sign-out; revocation of previous account clients and permits; and continued enforcement of bootstrap endpoint restrictions and local requirements.

GitOrigin-RevId: 083933e69508224f8232349ba3750c02329af06a
This commit is contained in:
cooper-oai
2026-09-30 01:44:14 +00:00
committed by copyberry
parent 2cc65cdd4c
commit d8f69ea8bc
4 changed files with 149 additions and 8 deletions
@@ -92,7 +92,8 @@ impl EmbeddedNetworkPolicy {
.restrict_to_endpoints(endpoint.parse().into_iter().collect()),
),
)
.with_local_bootstrap_factory(factory);
.with_local_bootstrap_factory(factory)
.with_application_network_policy(self.effective.policy());
auth
}
}
@@ -1,7 +1,10 @@
//! Embedded startup must keep the project selected by the caller.
//! Embedded startup preserves caller configuration and bootstrap access across authentication.
use super::EmbeddedNetworkPolicy;
use super::configure;
use crate::config_manager::ConfigManager;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
@@ -9,10 +12,137 @@ use codex_config::NoopThreadConfigLoader;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::set_project_trust_level;
use codex_http_client::DestinationPolicy;
use codex_http_client::HttpClientFactory;
use codex_http_client::NetworkPermit;
use codex_http_client::NetworkPolicyDenied;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use codex_protocol::config_types::TrustLevel;
use pretty_assertions::assert_eq;
use std::sync::Arc;
struct BootstrapExternalAuth(CodexAuth);
impl ExternalAuth for BootstrapExternalAuth {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async { Ok(self.0.clone()) })
}
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
self.resolve()
}
}
#[tokio::test]
async fn bootstrap_discovery_survives_identity_installation_but_account_access_is_revoked()
-> anyhow::Result<()> {
let home = tempfile::tempdir()?;
let overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.loader_overrides(overrides.clone())
.build()
.await?;
let policy = EmbeddedNetworkPolicy::load(&overrides).await;
let auth_config = policy.bind_bootstrap_auth(config.auth_config());
let bootstrap = auth_config.auth_route_config.http_client_factory().clone();
let bundle_url =
codex_backend_client::Client::new(config.chatgpt_base_url.clone(), bootstrap.clone())
.config_bundle_url()
.parse()?;
let content_url = "https://chatgpt.com/backend-api/codex/responses".parse()?;
let manager = AuthManager::new(
home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::Ephemeral,
/*forced_chatgpt_workspace_id*/ None,
Some(config.chatgpt_base_url.clone()),
AuthKeyringBackendKind::default(),
auth_config.auth_route_config,
)
.await;
assert!(manager.auth_cached().is_none());
bootstrap.network_policy().acquire(&bundle_url)?;
let mut previous_access: Option<(HttpClientFactory, NetworkPermit)> = None;
for identity in ["bootstrap-account-one", "bootstrap-account-two"] {
let token = format!(
"e30.{}.signature",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!({
"sub": "bootstrap-user",
"https://api.openai.com/auth": {
"chatgpt_user_id": "bootstrap-user",
"chatgpt_account_id": identity,
},
}))?),
);
let auth = CodexAuth::from_external_chatgpt_tokens(&token, identity, Some("enterprise"))?;
manager
.set_external_auth(Arc::new(BootstrapExternalAuth(auth)))
.await?;
if let Some((client, permit)) = previous_access.take() {
assert_eq!(permit.check(), Err(NetworkPolicyDenied::Revoked));
assert_eq!(
client.network_policy().acquire(&content_url).err(),
Some(NetworkPolicyDenied::Revoked)
);
}
// Discovery must remain possible after both initial login and a workspace change.
bootstrap.network_policy().acquire(&bundle_url)?;
assert_eq!(
bootstrap.network_policy().acquire(&content_url).err(),
Some(NetworkPolicyDenied::Destination)
);
assert_eq!(
manager
.application_network_policy()
.acquire(&content_url)
.err(),
Some(NetworkPolicyDenied::Unavailable)
);
assert!(policy.effective.publish(
policy.effective.policy().revision(),
DestinationPolicy::Unrestricted,
));
let client = manager.http_client_factory();
let permit = client.network_policy().acquire(&content_url)?;
previous_access = Some((client, permit));
}
let (client, permit) = previous_access.expect("the last identity has active account access");
manager.clear_external_auth();
assert_eq!(permit.check(), Err(NetworkPolicyDenied::Revoked));
assert_eq!(
client.network_policy().acquire(&content_url).err(),
Some(NetworkPolicyDenied::Revoked)
);
assert_eq!(
manager
.application_network_policy()
.acquire(&content_url)
.err(),
Some(NetworkPolicyDenied::Unavailable)
);
bootstrap.network_policy().acquire(&bundle_url)?;
// Losing local requirements must still close the bootstrap route.
policy.local.unavailable(policy.local.policy().revision());
assert_eq!(
bootstrap.network_policy().acquire(&bundle_url).err(),
Some(NetworkPolicyDenied::Unavailable)
);
Ok(())
}
#[tokio::test]
async fn startup_reloads_the_callers_selected_project() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;