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
This commit is contained in:
aagrawal-oai
2026-11-05 04:25:19 +00:00
committed by copyberry
parent 5c0b1ac71f
commit 529d965d80
17 changed files with 523 additions and 89 deletions
+69 -14
View File
@@ -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<String>,
/// 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");
+93 -17
View File
@@ -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<String>) -> anyhow::Result<CodexAuth> {
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<String>) -> anyhow::Result<CodexAuth> {
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<codex_core::config::Config> {
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)
}
@@ -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(())
}
@@ -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",
@@ -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<F, E>(
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 {
@@ -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 <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