mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user