From 529d965d80a36f7b5d9d9ab218d88daf41106748 Mon Sep 17 00:00:00 2001 From: aagrawal-oai Date: Thu, 5 Nov 2026 04:05:31 +0000 Subject: [PATCH] Add ChatGPT token stdin authentication to exec-server (#59569) ## What changed - Add `--chatgpt-token-stdin` for remote HTTPS registration and optional `--chatgpt-account-id`, which must match the token's account claim when present. Read a bounded, newline-terminated token from a pipe without persisting it to `auth.json`. - Load managed configuration using the supplied account, enforce account restrictions, and preserve configured policy-server authentication. Skip ChatGPT policy lookup only for known plans that do not require managed configuration. - Allow `--exit-on-stdin-close` to cancel pending policy loading, and redact the registration bearer from registry diagnostics. - Reject incompatible stdin credential options, including flags placed on opposite sides of `forward`, and disallow ChatGPT token stdin authentication with `forward`. ## Testing Add coverage for account-claim validation, policy selection, policy-server sign-in requirements, shutdown during policy loading, credential flag conflicts, and bearer redaction. GitOrigin-RevId: b6b50565e1f150b96edf3cee796909a991f1b47f --- .../tests/suite/v2/config_server_tests.rs | 112 ++++++++++++++++++ ..._server_help_documents_remote_options.snap | 6 + codex-rs/cli/tests/exec_server.rs | 37 ++++++ codex-rs/cloud-config/src/bundle_loader.rs | 60 ++++++++++ codex-rs/cloud-config/src/chatgpt.rs | 20 ++-- .../cloud-config/src/config_server_tests.rs | 33 ++++-- codex-rs/cloud-config/src/lib.rs | 1 + codex-rs/exec-server-cli/src/command.rs | 83 ++++++++++--- codex-rs/exec-server-cli/src/config.rs | 110 ++++++++++++++--- codex-rs/exec-server-cli/src/config_tests.rs | 20 ++++ .../src/exec_server_args_tests.rs | 16 ++- .../src/exec_server_telemetry.rs | 24 ++-- ...alone_help_documents_executor_options.snap | 6 + codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/remote.rs | 46 +++++-- codex-rs/exec-server/src/remote/auth.rs | 29 +++-- codex-rs/http-client/src/lib.rs | 8 ++ 17 files changed, 523 insertions(+), 89 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/config_server_tests.rs b/codex-rs/app-server/tests/suite/v2/config_server_tests.rs index f4854b8473..b936158273 100644 --- a/codex-rs/app-server/tests/suite/v2/config_server_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/config_server_tests.rs @@ -203,6 +203,118 @@ wire_api = 'responses' } } +#[cfg(unix)] +enum StdinTokenStartup { + SkipPolicy, + LoadPolicy, + RejectAccount, + CloseDuringPolicy, +} + +#[cfg(unix)] +#[test_case::test_case("pro", Some("endor"), StdinTokenStartup::SkipPolicy; "personal_skips_policy")] +#[test_case::test_case("enterprise", Some("endor"), StdinTokenStartup::LoadPolicy; "enterprise_loads_policy")] +#[test_case::test_case("pro", None, StdinTokenStartup::LoadPolicy; "unbound_account_loads_policy")] +#[test_case::test_case("unknown", Some("endor"), StdinTokenStartup::LoadPolicy; "unknown_plan_loads_policy")] +#[test_case::test_case("pro", Some("alderaan"), StdinTokenStartup::RejectAccount; "mismatched_account_never_registers")] +#[test_case::test_case("enterprise", Some("endor"), StdinTokenStartup::CloseDuringPolicy; "stdin_close_cancels_pending_policy")] +#[tokio::test] +async fn stdin_token_startup( + plan: &str, + claimed_id: Option<&str>, + expected: StdinTokenStartup, +) -> Result<()> { + use StdinTokenStartup::*; + use anyhow::Context; + use std::process::Stdio; + use std::time::Duration; + use tokio::io::AsyncWriteExt; + use wiremock::matchers::header; + + skip_if_no_network!(Ok(())); + let home = tempdir()?; + std::fs::write( + home.path().join("requirements.toml"), + "allowed_chatgpt_workspaces = ['endor']", + )?; + let registry = MockServer::start().await; + let (origin, ca, _tls) = https_mock_server(home.path(), ®istry).await?; + let timeout = Duration::from_secs(60); + let mut claims = app_test_support::ChatGptIdTokenClaims::new().plan_type(plan); + claims.chatgpt_account_id = claimed_id.map(str::to_owned); + let token = app_test_support::encode_id_token(&claims)?; + let authenticated = |route| { + Mock::given(path(route)) + .and(header("authorization", format!("Bearer {token}"))) + .and(header("chatgpt-account-id", "endor")) + }; + let policy_response = match expected { + LoadPolicy => ResponseTemplate::new(200).set_body_json(serde_json::json!({})), + CloseDuringPolicy => ResponseTemplate::new(200).set_delay(timeout), + SkipPolicy | RejectAccount => ResponseTemplate::new(400) + .set_body_json(serde_json::json!({"detail": "No active workspace"})), + }; + let policy = authenticated("/api/codex/config/bundle") + .respond_with(policy_response) + .expect(u64::from(matches!( + expected, + LoadPolicy | CloseDuringPolicy + ))) + .mount_as_scoped(®istry) + .await; + let register = authenticated("/cloud/environment/env/register") + .respond_with(ResponseTemplate::new(200).set_delay(timeout)) + .expect(u64::from(matches!(expected, SkipPolicy | LoadPolicy))) + .mount_as_scoped(®istry) + .await; + let mut child = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?) + .current_dir(home.path()) + .env("CODEX_HOME", home.path()) + .env("CODEX_CA_CERTIFICATE", ca) + .env("NO_PROXY", "127.0.0.1") + .args(["-c", &format!("chatgpt_base_url={origin:?}")]) + .arg("exec-server") + .args(["--remote", &origin]) + .args(["--environment-id", "env"]) + .args(["--chatgpt-token-stdin", "--exit-on-stdin-close"]) + .args(["--chatgpt-account-id", "endor"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let mut stdin = child.stdin.take().context("exec-server stdin")?; + stdin.write_all(format!("{token}\n").as_bytes()).await?; + let (pending, exit_timeout) = match expected { + RejectAccount => { + let output = tokio::time::timeout(timeout, child.wait_with_output()).await??; + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("--chatgpt-account-id does not match the token") + ); + assert!( + registry + .received_requests() + .await + .context("request recording")? + .is_empty() + ); + return Ok(()); + } + CloseDuringPolicy => (&policy, Duration::from_secs(5)), + SkipPolicy | LoadPolicy => (®ister, timeout), + }; + tokio::time::timeout(timeout, pending.wait_until_satisfied()).await?; + drop(stdin); + assert!( + tokio::time::timeout(exit_timeout, child.wait()) + .await?? + .success() + ); + assert!(!home.path().join("auth.json").exists()); + Ok(()) +} + async fn https_mock_server( home: &Path, policy: &MockServer, diff --git a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap index 00d35d245f..3cddcfa1c5 100644 --- a/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap +++ b/codex-rs/cli/src/snapshots/codex__exec_server_args_tests__exec_server_help_documents_remote_options.snap @@ -106,6 +106,12 @@ Options: Read rotating executor credentials from stdin; exit when the pipe closes + --chatgpt-token-stdin + Read a ChatGPT access token from stdin for HTTPS registration + + --chatgpt-account-id + ChatGPT account ID. Must match the token's account claim when present + --aws-sigv4 Sign Direct registration and WebSocket handshake requests with AWS SigV4 diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs index 689fff9f1f..8469b2ffee 100644 --- a/codex-rs/cli/tests/exec_server.rs +++ b/codex-rs/cli/tests/exec_server.rs @@ -711,6 +711,43 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr Ok(()) } +#[tokio::test] +async fn forward_rejects_stdin_credentials_on_opposite_sides_of_subcommand() -> Result<()> { + let codex = codex_utils_cargo_bin::cargo_bin("codex")?; + let home = TempDir::new()?; + let remote = [ + "--remote", + "https://registry.example", + "--environment-id", + "env-1", + ]; + let bearer = "--registration-bearer-stdin"; + let rotating = "--executor-credential-stdin"; + for (before, after) in [(bearer, rotating), (rotating, bearer)] { + let mut child = tokio::process::Command::new(&codex) + .env("CODEX_HOME", home.path()) + .arg("exec-server") + .args(remote) + .args([before, "forward", "--connect", "ws://127.0.0.1:8765"]) + .args(remote) + .arg(after) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + // Keep the empty pipe open so either credential read would block. + let _stdin = child.stdin.take().context("credential pipe")?; + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .context("exec-server waited for a credential")??; + assert!(!output.status.success()); + assert!(String::from_utf8(output.stderr)?.contains( + "--registration-bearer-stdin cannot be used with --executor-credential-stdin" + )); + } + Ok(()) +} + #[tokio::test] async fn remote_exec_server_scoped_credentials_authenticate_only_register_and_validate() -> Result<()> { diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 6c79a49c54..9fc34002d0 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -1,5 +1,6 @@ use crate::backend::BackendBundleClient; use crate::chatgpt::ChatGptSource; +use crate::chatgpt::plan_requires_managed_config; use crate::config_server::config_server_http_client_factory; use crate::config_source::BundleSource; use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT; @@ -9,6 +10,8 @@ use codex_config::ConfigRequirementsToml; use codex_http_client::HttpClientFactory; use codex_login::AuthConfig; use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_protocol::account::PlanType; use std::path::PathBuf; use std::sync::Arc; use tokio::task::AbortHandle; @@ -164,6 +167,63 @@ pub async fn chatgpt_config_bundle_loader_for_storage( Ok(loader) } +/// Use the configured policy server or the supplied account's ChatGPT policy when eligible. +pub async fn managed_config_bundle_loader_for_auth( + config: AuthConfig, + auth: &CodexAuth, + requirements: &ConfigRequirementsToml, +) -> std::io::Result { + if requirements.config_server.is_some() { + return managed_config_bundle_loader_for_storage( + config, + requirements, + /*enable_codex_api_key_env*/ false, + ) + .await; + } + // Only skip the policy lookup when the supplied account's plan is known. + if auth + .account_plan_type() + .is_some_and(|plan| plan != PlanType::Unknown && !plan_requires_managed_config(plan)) + { + return Ok(CloudConfigBundleLoader::default()); + } + + let base_url = config + .chatgpt_base_url + .as_deref() + .unwrap_or("https://chatgpt.com/backend-api/"); + codex_http_client::validate_https_endpoint_url(base_url, "ChatGPT policy endpoint") + .map_err(std::io::Error::other)?; + let mut client = codex_backend_client::Client::new_without_redirects( + base_url, + config.auth_route_config.http_client_factory().clone(), + ) + .with_user_agent(codex_login::default_client::get_codex_user_agent()) + .with_bearer_token(auth.get_token()?) + .map_err(std::io::Error::other)? + .with_chatgpt_account_id( + auth.get_account_id() + .ok_or_else(|| std::io::Error::other("missing ChatGPT account ID"))?, + ); + if auth.is_fedramp_account() { + client = client.with_fedramp_routing_header(); + } + let response = tokio::time::timeout(CLOUD_CONFIG_BUNDLE_TIMEOUT, client.get_config_bundle()) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "timed out loading ChatGPT account policy", + ) + })? + .map_err(|_| std::io::Error::other("could not load ChatGPT account policy"))?; + let bundle = crate::backend::bundle_from_response(response); + Ok(CloudConfigBundleLoader::new( + async move { Ok(Some(bundle)) }, + )) +} + /// Fetches directly from the network on each load, without reading or writing /// the disk cache or starting a background refresher. pub async fn chatgpt_config_bundle_loader_for_storage_without_cache( diff --git a/codex-rs/cloud-config/src/chatgpt.rs b/codex-rs/cloud-config/src/chatgpt.rs index 4d2c813fd2..47a3b2cf24 100644 --- a/codex-rs/cloud-config/src/chatgpt.rs +++ b/codex-rs/cloud-config/src/chatgpt.rs @@ -18,6 +18,15 @@ const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( "Please log out and sign in again." ); +pub(super) fn plan_requires_managed_config(plan: PlanType) -> bool { + plan.is_business_like() + || plan.is_education_like() + || matches!( + plan, + PlanType::Enterprise | PlanType::Sci | PlanType::SciPlus + ) +} + pub(crate) struct ChatGptSource { pub(crate) auth_manager: Arc, pub(crate) client: Arc, @@ -33,14 +42,9 @@ impl BundleSource for ChatGptSource { async fn auth(&self) -> Result, CloudConfigBundleLoadError> { Ok(self.auth_manager.auth().await.filter(|auth| { auth.uses_codex_backend() - && auth.account_plan_type().is_some_and(|plan| { - plan.is_business_like() - || plan.is_education_like() - || matches!( - plan, - PlanType::Enterprise | PlanType::Sci | PlanType::SciPlus - ) - }) + && auth + .account_plan_type() + .is_some_and(plan_requires_managed_config) })) } diff --git a/codex-rs/cloud-config/src/config_server_tests.rs b/codex-rs/cloud-config/src/config_server_tests.rs index 2a09f7261e..57afe4030e 100644 --- a/codex-rs/cloud-config/src/config_server_tests.rs +++ b/codex-rs/cloud-config/src/config_server_tests.rs @@ -49,21 +49,34 @@ fn oauth_config(base_url: &str) -> OAuthSessionConfig { } #[tokio::test] -async fn policy_fetch_requires_config_server_sign_in() { +async fn a_supplied_chatgpt_token_does_not_replace_config_server_sign_in() { let server = MockServer::start().await; let home = tempdir().unwrap(); let endpoint = format!("https://{}", server.address()); - let factory = transport_default_auth_route_config() - .http_client_factory() - .clone(); - let loader = config_server_bundle_loader( - ConfigServer { + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(home.path().into()) + .build() + .await + .unwrap() + .auth_config(); + config.chatgpt_base_url = Some(server.uri()); + let requirements = codex_config::ConfigRequirementsToml { + config_server: Some(ConfigServer { base_url: format!("{endpoint}/settings"), oauth: Some(Box::new(oauth_config(&endpoint))), - }, - home.path().to_path_buf(), - factory, - ); + }), + ..Default::default() + }; + let chatgpt = codex_login::CodexAuth::from_external_chatgpt_tokens( + "e30.e30.c2ln", + "endor", + /*chatgpt_plan_type*/ None, + ) + .unwrap(); + let loader = crate::managed_config_bundle_loader_for_auth(config, &chatgpt, &requirements) + .await + .unwrap(); + assert_eq!(loader.config_server(), requirements.config_server.as_ref()); let error = loader.get().await.unwrap_err(); assert_eq!(error.code(), CloudConfigBundleLoadErrorCode::Auth); assert_eq!( diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index be5edd07ec..3d0dab6018 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -19,6 +19,7 @@ pub use bundle_loader::chatgpt_config_bundle_loader_for_storage_without_cache; pub use bundle_loader::ensure_config_server_login; pub use bundle_loader::managed_config_auth_manager; pub use bundle_loader::managed_config_bundle_loader; +pub use bundle_loader::managed_config_bundle_loader_for_auth; pub use bundle_loader::managed_config_bundle_loader_for_storage; pub use config_server::config_server_auth_manager; pub use config_server::config_server_bundle_loader; diff --git a/codex-rs/exec-server-cli/src/command.rs b/codex-rs/exec-server-cli/src/command.rs index dd4848f026..0615a2c9e9 100644 --- a/codex-rs/exec-server-cli/src/command.rs +++ b/codex-rs/exec-server-cli/src/command.rs @@ -15,9 +15,11 @@ use codex_websocket_auth::WebsocketAuthArgs; use crate::config::load_exec_server_config; use crate::config::load_exec_server_remote_auth_provider; +use crate::config::read_chatgpt_stdin_auth; use crate::exec_server_auth; use crate::exec_server_credential::ExecutorCredentialPipe; use crate::exec_server_telemetry; +use crate::exec_server_telemetry::ParentLifetime; /// [EXPERIMENTAL] Run the standalone exec-server service. #[derive(Debug, Parser)] @@ -124,6 +126,18 @@ pub struct ExecServerCommand { )] executor_credential_stdin: bool, + /// Read a ChatGPT access token from stdin for HTTPS registration. + #[arg( + long, + requires = "exec_server_remote", + conflicts_with_all = ["use_agent_identity_auth", "aws_sigv4", "executor_credential_stdin", "registration_bearer_stdin"] + )] + chatgpt_token_stdin: bool, + + /// ChatGPT account ID. Must match the token's account claim when present. + #[arg(long, value_name = "ID", requires = "chatgpt_token_stdin")] + chatgpt_account_id: Option, + /// Sign Direct registration and WebSocket handshake requests with AWS SigV4. #[arg(long = "aws-sigv4", requires = "exec_server_remote", global = true)] aws_sigv4: bool, @@ -207,30 +221,55 @@ impl ExecServerCommand { .registration_bearer_stdin .then(codex_exec_server::registration_auth_from_stdin) .transpose()?; - let config = - load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config) - .await?; + let stdin_auth = if self.chatgpt_token_stdin { + codex_http_client::validate_https_endpoint_url(&base_url, "ChatGPT token registry") + .map_err(anyhow::Error::msg)?; + Some(read_chatgpt_stdin_auth(self.chatgpt_account_id.take())?) + } else { + None + }; + let config = load_exec_server_config( + root_config_overrides, + strict_config, + enable_cloud_config, + stdin_auth.as_ref(), + ); + let mut parent_lifetime = if self.exit_on_stdin_close && stdin_auth.is_some() { + ParentLifetime::MonitoredStdin(exec_server_telemetry::monitor_stdin_close()) + } else if self.exit_on_stdin_close { + ParentLifetime::StdinPipe + } else { + ParentLifetime::Independent + }; + let config = match &mut parent_lifetime { + ParentLifetime::MonitoredStdin(closed) => tokio::select! { + biased; + _ = closed => return Ok(()), + config = config => config?, + }, + ParentLifetime::StdinPipe | ParentLifetime::Independent => 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. let telemetry_config = (!self.executor_credential_stdin).then_some(&config); let (_otel, telemetry) = exec_server_telemetry::init(telemetry_config); let mut credential_provider = None; - let mut parent_lifetime = if self.exit_on_stdin_close { - exec_server_telemetry::ParentLifetime::StdinPipe - } else { - exec_server_telemetry::ParentLifetime::Independent - }; let auth_provider: codex_api::SharedAuthProvider = if self.executor_credential_stdin { let ExecutorCredentialPipe { provider, parent_closed, } = ExecutorCredentialPipe::from_stdin(environment_id.trim().to_string()); provider.wait_for_initial().await?; - parent_lifetime = - exec_server_telemetry::ParentLifetime::MonitoredStdin(parent_closed); + parent_lifetime = ParentLifetime::MonitoredStdin(parent_closed); credential_provider = Some(Arc::clone(&provider)); provider + } else if let Some(auth) = stdin_auth { + anyhow::ensure!( + config.auth_config().allows_auth(&auth), + "ChatGPT authentication is not permitted for this account" + ); + codex_model_provider::auth_provider_from_auth(&auth) } else if self.aws_sigv4 { exec_server_auth::aws_sigv4_auth_provider( codex_aws_auth::AwsAuthConfig { @@ -264,6 +303,8 @@ impl ExecServerCommand { )?; if self.executor_credential_stdin || self.registration_bearer_stdin { remote_config = remote_config.with_executor_credential_auth()?; + } else if self.chatgpt_token_stdin { + remote_config = remote_config.with_registry_bearer_redaction(); } if let Some(name) = self.name { remote_config.name = name; @@ -312,9 +353,13 @@ impl ExecServerCommand { } result } else { - let config_result = - load_exec_server_config(root_config_overrides, strict_config, enable_cloud_config) - .await; + let config_result = load_exec_server_config( + root_config_overrides, + strict_config, + enable_cloud_config, + /*stdin_auth*/ None, + ) + .await; let config = if strict_config { Some(config_result?) } else { @@ -346,7 +391,7 @@ impl ExecServerCommand { self.request_dispatch_mode, websocket_auth, ), - exec_server_telemetry::ParentLifetime::Independent, + ParentLifetime::Independent, exec_server_telemetry::ShutdownBehavior::Immediate, ); run.await.map_err(anyhow::Error::from_boxed) @@ -363,6 +408,16 @@ impl ExecServerCommand { } fn validate_remote_transport(&self) -> anyhow::Result<()> { + // Clap checks global flag conflicts separately on either side of `forward`. + if self.registration_bearer_stdin && self.executor_credential_stdin { + anyhow::bail!( + "--registration-bearer-stdin cannot be used with --executor-credential-stdin" + ); + } + anyhow::ensure!( + !self.chatgpt_token_stdin || self.command.is_none(), + "--chatgpt-token-stdin cannot be used with forward" + ); match (self.remote_transport, self.aws_sigv4) { (ExecServerRemoteTransport::Noise, true) => { anyhow::bail!("--aws-sigv4 requires --remote-transport direct"); diff --git a/codex-rs/exec-server-cli/src/config.rs b/codex-rs/exec-server-cli/src/config.rs index 4911f27b96..464d8bda61 100644 --- a/codex-rs/exec-server-cli/src/config.rs +++ b/codex-rs/exec-server-cli/src/config.rs @@ -1,7 +1,9 @@ //! Loads executor startup configuration and remote authentication without changing CLI policy. +use anyhow::Context; use codex_app_server::in_process::EmbeddedNetworkPolicy; use codex_cloud_config::chatgpt_config_bundle_loader; +use codex_cloud_config::managed_config_bundle_loader_for_auth; use codex_config::LoaderOverrides; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigLoadOptions; @@ -14,6 +16,8 @@ use codex_login::read_codex_access_token_from_env; use codex_protocol::auth::AuthMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use std::io::BufRead; +use std::io::Read; use std::sync::Arc; use crate::exec_server_auth; @@ -25,6 +29,61 @@ const REMOTE_AUTH_MODES: &[AuthMode] = &[ AuthMode::PersonalAccessToken, ]; +pub(super) fn read_chatgpt_stdin_auth(account_id: Option) -> anyhow::Result { + const MAX_FRAME_BYTES: u64 = 64 * 1024; + let stdin = codex_exec_server::credential_stdin()?; + #[cfg(unix)] + { + use std::os::fd::AsFd; + use std::os::unix::fs::FileTypeExt; + let kind = std::fs::File::from(stdin.as_fd().try_clone_to_owned()?) + .metadata()? + .file_type(); + anyhow::ensure!( + kind.is_fifo() || kind.is_socket(), + "ChatGPT token stdin must be a pipe or socket" + ); + } + #[cfg(not(unix))] + anyhow::ensure!( + !std::io::IsTerminal::is_terminal(&stdin), + "ChatGPT token stdin must be piped" + ); + + // The launcher keeps the pipe open for --exit-on-stdin-close after this line. + let mut line = String::new(); + stdin.lock().take(MAX_FRAME_BYTES).read_line(&mut line)?; + chatgpt_auth_from_line(&line, account_id) +} + +fn chatgpt_auth_from_line(line: &str, account_id: Option) -> anyhow::Result { + let token = line + .strip_suffix('\n') + .context("expected a bounded, newline-terminated ChatGPT access token")?; + let token = token.strip_suffix('\r').unwrap_or(token); + anyhow::ensure!( + !token.is_empty() && token.bytes().all(|b| b.is_ascii_graphic()), + "invalid ChatGPT access token" + ); + let claimed_id = codex_login::token_data::parse_chatgpt_jwt_claims(token) + .map_err(|_| anyhow::anyhow!("invalid ChatGPT access token"))? + .chatgpt_account_id; + if let (Some(id), Some(claim)) = (&account_id, &claimed_id) { + anyhow::ensure!(id == claim, "--chatgpt-account-id does not match the token"); + } + // A token without an account claim cannot establish the supplied account's plan. + let plan_override = claimed_id.is_none().then_some("unknown"); + let account_id = account_id + .or(claimed_id) + .context("token has no ChatGPT account ID; supply --chatgpt-account-id")?; + anyhow::ensure!( + !account_id.is_empty() && account_id.bytes().all(|b| b.is_ascii_graphic()), + "invalid ChatGPT account ID" + ); + CodexAuth::from_external_chatgpt_tokens(token, &account_id, plan_override) + .map_err(|_| anyhow::anyhow!("invalid ChatGPT access token")) +} + pub(super) async fn load_exec_server_remote_auth_provider( config: &codex_core::config::Config, base_url: &str, @@ -120,6 +179,7 @@ pub(super) async fn load_exec_server_config( root_config_overrides: &CliConfigOverrides, strict_config: bool, enable_cloud_config: bool, + stdin_auth: Option<&CodexAuth>, ) -> anyhow::Result { let cli_kv_overrides = root_config_overrides .parse_overrides() @@ -146,26 +206,42 @@ pub(super) async fn load_exec_server_config( .await?; let bootstrap_auth_config = network_policy .bind_bootstrap_auth(bootstrap_auth_config(&codex_home, &bootstrap_config)?); - let auth_manager = AuthManager::shared_from_auth_config_with_auth_modes( - bootstrap_auth_config.clone(), - /*enable_codex_api_key_env*/ true, - REMOTE_AUTH_MODES.to_vec(), - ) - .await?; - let cloud_config_bundle = chatgpt_config_bundle_loader( - auth_manager, - bootstrap_auth_config - .chatgpt_base_url - .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), - bootstrap_auth_config.codex_home, - bootstrap_auth_config - .auth_route_config - .http_client_factory() - .clone(), - ); + let cloud_config_bundle = if let Some(auth) = stdin_auth { + anyhow::ensure!( + bootstrap_auth_config.allows_auth(auth), + "ChatGPT authentication is not permitted for this account" + ); + managed_config_bundle_loader_for_auth( + bootstrap_auth_config, + auth, + bootstrap_config.config_layer_stack.requirements_toml(), + ) + .await? + } else { + let auth_manager = AuthManager::shared_from_auth_config_with_auth_modes( + bootstrap_auth_config.clone(), + /*enable_codex_api_key_env*/ true, + REMOTE_AUTH_MODES.to_vec(), + ) + .await?; + chatgpt_config_bundle_loader( + auth_manager, + bootstrap_auth_config + .chatgpt_base_url + .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()), + bootstrap_auth_config.codex_home, + bootstrap_auth_config + .auth_route_config + .http_client_factory() + .clone(), + ) + }; builder = builder.cloud_config_bundle(cloud_config_bundle); } let mut config = builder.build().await?; + if stdin_auth.is_some() { + config.config_layer_stack.ensure_config_server_applied()?; + } EmbeddedNetworkPolicy::default().activate(&mut config); Ok(config) } diff --git a/codex-rs/exec-server-cli/src/config_tests.rs b/codex-rs/exec-server-cli/src/config_tests.rs index 8ca9411242..a04171168b 100644 --- a/codex-rs/exec-server-cli/src/config_tests.rs +++ b/codex-rs/exec-server-cli/src/config_tests.rs @@ -59,3 +59,23 @@ fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" ); } + +#[test] +fn stdin_chatgpt_account_must_match_the_token_claim() -> anyhow::Result<()> { + let claims = + serde_json::json!({"https://api.openai.com/auth": {"chatgpt_account_id": "alderaan"}}); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret(b"test"), + )?; + let line = format!("{token}\n"); + for account_id in [None, Some("alderaan".to_string())] { + let auth = chatgpt_auth_from_line(&line, account_id)?; + assert_eq!(auth.get_account_id().as_deref(), Some("alderaan")); + } + assert!(chatgpt_auth_from_line(&line, Some("endor".to_string())).is_err()); + assert!(chatgpt_auth_from_line("e30.e30.c2ln\n", /*account_id*/ None).is_err()); + assert!(chatgpt_auth_from_line(&token, /*account_id*/ None).is_err()); + Ok(()) +} diff --git a/codex-rs/exec-server-cli/src/exec_server_args_tests.rs b/codex-rs/exec-server-cli/src/exec_server_args_tests.rs index eb7fdf5b8b..d000825a2d 100644 --- a/codex-rs/exec-server-cli/src/exec_server_args_tests.rs +++ b/codex-rs/exec-server-cli/src/exec_server_args_tests.rs @@ -50,7 +50,7 @@ fn exec_server_defaults_preserve_local_and_noise_modes() { #[test] fn remote_forwarder_skips_cloud_config() { - let command = exec_server_from_args(&[ + let mut command = exec_server_from_args(&[ "forward", "--connect", "ws://localhost:8765", @@ -60,6 +60,8 @@ fn remote_forwarder_skips_cloud_config() { "env-1", ]); assert!(!command.should_load_cloud_config()); + command.chatgpt_token_stdin = true; + assert!(command.validate_remote_transport().is_err()); } #[test] @@ -165,6 +167,18 @@ fn exec_server_rejects_missing_or_conflicting_remote_options() { vec!["--executor-credential-stdin", "--use-agent-identity-auth"], ErrorKind::ArgumentConflict, ), + ( + vec!["--executor-credential-stdin", "--registration-bearer-stdin"], + ErrorKind::ArgumentConflict, + ), + ( + vec!["--chatgpt-token-stdin", "--executor-credential-stdin"], + ErrorKind::ArgumentConflict, + ), + ( + vec!["--chatgpt-token-stdin", "--registration-bearer-stdin"], + ErrorKind::ArgumentConflict, + ), ( vec![ "--executor-credential-stdin", diff --git a/codex-rs/exec-server-cli/src/exec_server_telemetry.rs b/codex-rs/exec-server-cli/src/exec_server_telemetry.rs index 001c84fafa..8b33797153 100644 --- a/codex-rs/exec-server-cli/src/exec_server_telemetry.rs +++ b/codex-rs/exec-server-cli/src/exec_server_telemetry.rs @@ -58,6 +58,17 @@ pub(crate) fn init( (otel, telemetry) } +pub(super) fn monitor_stdin_close() -> tokio::sync::oneshot::Receiver<()> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + if let Err(error) = std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink()) { + tracing::warn!(%error, "Could not read exec-server parent lifetime pipe"); + } + let _ = sender.send(()); + }); + receiver +} + pub(crate) async fn run_until_shutdown( run: F, parent_lifetime: ParentLifetime, @@ -69,18 +80,7 @@ where let parent_disconnected = match parent_lifetime { ParentLifetime::Independent => None, ParentLifetime::MonitoredStdin(receiver) => Some(receiver), - ParentLifetime::StdinPipe => { - let (sender, receiver) = tokio::sync::oneshot::channel(); - std::thread::spawn(move || { - if let Err(error) = - std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink()) - { - tracing::warn!(%error, "Could not read exec-server parent lifetime pipe"); - } - let _ = sender.send(()); - }); - Some(receiver) - } + ParentLifetime::StdinPipe => Some(monitor_stdin_close()), }; let parent_disconnected = async { match parent_disconnected { diff --git a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap index 723533f981..5246746c60 100644 --- a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap +++ b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap @@ -100,6 +100,12 @@ Options: Read rotating executor credentials from stdin; exit when the pipe closes + --chatgpt-token-stdin + Read a ChatGPT access token from stdin for HTTPS registration + + --chatgpt-account-id + ChatGPT account ID. Must match the token's account claim when present + --aws-sigv4 Sign Direct registration and WebSocket handshake requests with AWS SigV4 diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 138570fcdb..4ef470c149 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -239,6 +239,7 @@ pub use protocol::WriteStatus; pub use regular_file::read_sensitive_file_to_string; pub use remote::RemoteEnvironmentConfig; pub use remote::RemoteEnvironmentTransport; +pub use remote::credential_stdin; pub use remote::registration_auth_from_stdin; pub use remote::run_remote_environment; pub use remote::run_remote_environment_forward_until_shutdown; diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index 6c250b9946..e365547487 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -81,6 +81,7 @@ pub enum RemoteEnvironmentTransport { enum RegistryAuthMode { Legacy, ExecutorCredential, + ChatgptToken, } #[derive(Clone)] @@ -307,16 +308,17 @@ impl EnvironmentRegistryClient { .apply_auth_headers(HeaderMap::new()) .await .map_err(auth::registry_auth_error)?; - let executor_credential = if self.auth_mode == RegistryAuthMode::ExecutorCredential { - // Scoped mode sends exactly one bearer and never accompanying account credentials. - let token = (headers.len() == 1) + let scoped = self.auth_mode == RegistryAuthMode::ExecutorCredential; + let credential = if self.auth_mode != RegistryAuthMode::Legacy { + // Scoped credentials must be the only header. ChatGPT also carries its account ID. + let token = (!scoped || headers.len() == 1) .then(|| headers.get(AUTHORIZATION)) .flatten() .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .filter(|token| { !token.is_empty() - && token.len() <= 16 * 1024 + && (!scoped || token.len() <= 16 * 1024) && token.bytes().all(|byte| byte.is_ascii_graphic()) }) .ok_or_else(|| { @@ -333,12 +335,14 @@ impl EnvironmentRegistryClient { if let Some(authorization) = headers.get_mut(AUTHORIZATION) { authorization.set_sensitive(true); } - direct::require_tls_or_loopback(&self.base_url, "https")?; + if scoped { + direct::require_tls_or_loopback(&self.base_url, "https")?; + } Some(credential) } else { None }; - Ok((headers, executor_credential)) + Ok((headers, credential)) } fn http_for_auth(&self) -> &RouteAwareClientPool { @@ -567,17 +571,20 @@ impl AuthProvider for StaticBearerAuthProvider { } } +/// Return stdin after protecting the credential pipe and process memory on Linux. +pub fn credential_stdin() -> std::io::Result { + #[cfg(target_os = "linux")] + rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable)?; + Ok(std::io::stdin()) +} + /// Read a single newline-terminated registration bearer from a parent-owned pipe. pub fn registration_auth_from_stdin() -> std::io::Result { use std::io::BufRead; use std::io::Read; - // Deny same-user access to the pipe and credential memory before reading. - #[cfg(target_os = "linux")] - rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable)?; - let mut token = String::new(); - std::io::stdin() + credential_stdin()? .lock() .take(16 * 1024) .read_line(&mut token)?; @@ -717,6 +724,12 @@ impl RemoteEnvironmentConfig { Ok(self) } + /// Omit the bearer from registry diagnostics while retaining the account header. + pub fn with_registry_bearer_redaction(mut self) -> Self { + self.auth_mode = RegistryAuthMode::ChatgptToken; + self + } + pub fn with_telemetry(mut self, telemetry: ExecServerTelemetry) -> Self { self.telemetry = telemetry; self @@ -1461,6 +1474,15 @@ mod tests { #[tokio::test] async fn executor_credential_is_never_returned_in_registry_diagnostics() { + assert_registry_diagnostics_omit_bearer(RegistryAuthMode::ExecutorCredential).await; + } + + #[tokio::test] + async fn chatgpt_token_is_never_returned_in_registry_diagnostics() { + assert_registry_diagnostics_omit_bearer(RegistryAuthMode::ChatgptToken).await; + } + + async fn assert_registry_diagnostics_omit_bearer(mode: RegistryAuthMode) { const TOKEN: &str = "synthetic-scoped-registry-secret"; struct ExecutorAuth; impl AuthProvider for ExecutorAuth { @@ -1516,7 +1538,7 @@ mod tests { .await; let mut client = EnvironmentRegistryClient::new(server.uri(), Arc::new(ExecutorAuth)) .expect("registry client"); - client.auth_mode = RegistryAuthMode::ExecutorCredential; + client.auth_mode = mode; let public_key = NoiseChannelIdentity::generate() .expect("identity") .public_key(); diff --git a/codex-rs/exec-server/src/remote/auth.rs b/codex-rs/exec-server/src/remote/auth.rs index e78de93991..23aa792eb0 100644 --- a/codex-rs/exec-server/src/remote/auth.rs +++ b/codex-rs/exec-server/src/remote/auth.rs @@ -36,21 +36,20 @@ impl EnvironmentRegistryClient { let send = async { let mut retried = false; loop { - let (authenticated, credential) = - if self.auth_mode == RegistryAuthMode::ExecutorCredential { - let (headers, credential) = self.resolve_auth().await?; - let mut authenticated = request.clone(); - authenticated.headers.extend(headers); - (authenticated, credential) - } else { - ( - self.auth_provider - .apply_auth(request.clone()) - .await - .map_err(registry_auth_error)?, - None, - ) - }; + let (authenticated, credential) = if self.auth_mode != RegistryAuthMode::Legacy { + let (headers, credential) = self.resolve_auth().await?; + let mut authenticated = request.clone(); + authenticated.headers.extend(headers); + (authenticated, credential) + } else { + ( + self.auth_provider + .apply_auth(request.clone()) + .await + .map_err(registry_auth_error)?, + None, + ) + }; let prepared = authenticated .prepare_body_for_send() .map_err(ExecServerError::EnvironmentRegistryConfig)?; diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index 9eec07dd39..e5007e1c43 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -119,3 +119,11 @@ pub fn validate_endpoint_url(value: &str, field: &str) -> Result { } Ok(url) } + +/// Validates a credential-bearing HTTPS endpoint without including the URL in errors. +pub fn validate_https_endpoint_url(value: &str, field: &str) -> Result { + validate_endpoint_url(value, field) + .ok() + .filter(|url| url.scheme() == "https") + .ok_or_else(|| format!("{field} URLs must use HTTPS without userinfo or fragments")) +}