mirror of
https://github.com/openai/codex.git
synced 2026-03-25 17:46:50 +03:00
Compare commits
9 Commits
stack/anal
...
codex/api-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26779cd160 | ||
|
|
fde3495c53 | ||
|
|
f681d22672 | ||
|
|
31c3dd85a8 | ||
|
|
d8e18f6b87 | ||
|
|
c7b44e07d4 | ||
|
|
1cddd263a9 | ||
|
|
3004f40703 | ||
|
|
60a44baf4d |
4
.github/workflows/rust-ci.yml
vendored
4
.github/workflows/rust-ci.yml
vendored
@@ -494,8 +494,10 @@ jobs:
|
||||
|
||||
# Save caches explicitly; make non-fatal so cache packaging
|
||||
# never fails the overall job. Only save when key wasn't hit.
|
||||
# Lint/build jobs already save this cache key. Skipping the redundant
|
||||
# Windows test-job save keeps the test jobs within their timeout budget.
|
||||
- name: Save cargo home cache
|
||||
if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
|
||||
if: always() && !cancelled() && !startsWith(matrix.runner, 'windows') && steps.cache_cargo_home_restore.outputs.cache-hit != 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
|
||||
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2222,6 +2222,7 @@ dependencies = [
|
||||
"codex-keyring-store",
|
||||
"codex-protocol",
|
||||
"codex-terminal-detection",
|
||||
"codex-utils-home-dir",
|
||||
"core_test_support",
|
||||
"keyring",
|
||||
"once_cell",
|
||||
|
||||
@@ -17,6 +17,7 @@ codex-config = { workspace = true }
|
||||
codex-keyring-store = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
12
codex-rs/login/src/bin/get_sensitive_id_via_codex_oauth.rs
Normal file
12
codex-rs/login/src/bin/get_sensitive_id_via_codex_oauth.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(err) = codex_login::run_onboard_oauth_helper_from_env().await {
|
||||
eprintln!("{err}");
|
||||
if let Some(body) = err.body()
|
||||
&& !body.is_empty()
|
||||
{
|
||||
eprintln!("{body}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
141
codex-rs/login/src/dotenv_api_key.rs
Normal file
141
codex-rs/login/src/dotenv_api_key.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::auth::OPENAI_API_KEY_ENV_VAR;
|
||||
|
||||
pub fn validate_dotenv_target(path: &Path) -> io::Result<()> {
|
||||
ensure_parent_dir(path)?;
|
||||
|
||||
if path.exists() {
|
||||
OpenOptions::new().append(true).open(path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
pub fn upsert_dotenv_api_key(path: &Path, api_key: &str) -> io::Result<()> {
|
||||
if api_key.contains(['\n', '\r']) {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"OPENAI_API_KEY must not contain newlines",
|
||||
));
|
||||
}
|
||||
|
||||
ensure_parent_dir(path)?;
|
||||
|
||||
let existing = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mut next = String::new();
|
||||
let mut wrote_api_key = false;
|
||||
|
||||
for segment in split_lines_preserving_terminators(&existing) {
|
||||
if is_active_assignment_for(segment, OPENAI_API_KEY_ENV_VAR) {
|
||||
if !wrote_api_key {
|
||||
next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n"));
|
||||
wrote_api_key = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
next.push_str(segment);
|
||||
}
|
||||
|
||||
if !wrote_api_key {
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str(&format!("{OPENAI_API_KEY_ENV_VAR}={api_key}\n"));
|
||||
}
|
||||
|
||||
std::fs::write(path, next)
|
||||
}
|
||||
|
||||
fn ensure_parent_dir(path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_lines_preserving_terminators(contents: &str) -> Vec<&str> {
|
||||
if contents.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
contents.split_inclusive('\n').collect()
|
||||
}
|
||||
|
||||
fn is_active_assignment_for(line: &str, key: &str) -> bool {
|
||||
let mut rest = line.trim_start();
|
||||
if rest.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(stripped) = rest.strip_prefix("export") {
|
||||
rest = stripped.trim_start();
|
||||
}
|
||||
|
||||
let Some(rest) = rest.strip_prefix(key) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
rest.trim_start().starts_with('=')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_dotenv_file_when_missing() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let dotenv_path = temp_dir.path().join(".env");
|
||||
|
||||
upsert_dotenv_api_key(&dotenv_path, "sk-test-key").expect("write dotenv");
|
||||
|
||||
let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv");
|
||||
assert_eq!(written, "OPENAI_API_KEY=sk-test-key\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_existing_api_key_and_collapses_duplicates() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let dotenv_path = temp_dir.path().join(".env");
|
||||
std::fs::write(
|
||||
&dotenv_path,
|
||||
"# comment\nOPENAI_API_KEY=sk-old-1\nOTHER=value\nexport OPENAI_API_KEY = sk-old-2\n",
|
||||
)
|
||||
.expect("seed dotenv");
|
||||
|
||||
upsert_dotenv_api_key(&dotenv_path, "sk-new-key").expect("update dotenv");
|
||||
|
||||
let written = std::fs::read_to_string(&dotenv_path).expect("read dotenv");
|
||||
assert_eq!(
|
||||
written,
|
||||
"# comment\nOPENAI_API_KEY=sk-new-key\nOTHER=value\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dotenv_target_succeeds_for_missing_file() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let dotenv_path = temp_dir.path().join(".env");
|
||||
|
||||
validate_dotenv_target(&dotenv_path).expect("validate dotenv");
|
||||
|
||||
assert!(
|
||||
!dotenv_path.exists(),
|
||||
"validation should not leave behind a new file"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ pub mod auth;
|
||||
pub mod token_data;
|
||||
|
||||
mod device_code_auth;
|
||||
mod dotenv_api_key;
|
||||
mod onboard_oauth_helper;
|
||||
mod pkce;
|
||||
mod server;
|
||||
|
||||
@@ -34,4 +36,12 @@ pub use auth::logout;
|
||||
pub use auth::read_openai_api_key_from_env;
|
||||
pub use auth::save_auth;
|
||||
pub use codex_app_server_protocol::AuthMode;
|
||||
pub use dotenv_api_key::upsert_dotenv_api_key;
|
||||
pub use dotenv_api_key::validate_dotenv_target;
|
||||
pub use onboard_oauth_helper::ApiProvisionOptions;
|
||||
pub use onboard_oauth_helper::HelperError as OnboardOauthHelperError;
|
||||
pub use onboard_oauth_helper::PendingApiProvisioning;
|
||||
pub use onboard_oauth_helper::ProvisionedApiKey;
|
||||
pub use onboard_oauth_helper::run_from_env as run_onboard_oauth_helper_from_env;
|
||||
pub use onboard_oauth_helper::start_api_provisioning;
|
||||
pub use token_data::TokenData;
|
||||
|
||||
855
codex-rs/login/src/onboard_oauth_helper.rs
Normal file
855
codex-rs/login/src/onboard_oauth_helper.rs
Normal file
@@ -0,0 +1,855 @@
|
||||
//! Browser-based helper for onboarding login and Codex auth provisioning.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
use reqwest::Client;
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::auth::AuthDotJson;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::server::AuthorizationCodeServer;
|
||||
use crate::server::start_authorization_code_server;
|
||||
|
||||
const AUTH_ISSUER: &str = "https://auth.openai.com";
|
||||
const PLATFORM_HYDRA_CLIENT_ID: &str = "app_2SKx67EdpoN0G6j64rFvigXD";
|
||||
const PLATFORM_AUDIENCE: &str = "https://api.openai.com/v1";
|
||||
const DEFAULT_API_BASE: &str = "https://api.openai.com";
|
||||
const DEFAULT_CALLBACK_PORT: u16 = 5000;
|
||||
const DEFAULT_CALLBACK_PATH: &str = "/auth/callback";
|
||||
const DEFAULT_SCOPE: &str = "openid email profile offline_access";
|
||||
const DEFAULT_APP: &str = "api";
|
||||
const DEFAULT_USER_AGENT: &str = "OpenAI-Onboard-Auth-Script/1.0";
|
||||
const DEFAULT_PROJECT_API_KEY_NAME: &str = "Codex CLI";
|
||||
const DEFAULT_PROJECT_POLL_INTERVAL_SECONDS: u64 = 10;
|
||||
const DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS: u64 = 60;
|
||||
const OAUTH_TIMEOUT_SECONDS: u64 = 15 * 60;
|
||||
const HTTP_TIMEOUT_SECONDS: u64 = 30;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiProvisionOptions {
|
||||
pub issuer: String,
|
||||
pub client_id: String,
|
||||
pub audience: String,
|
||||
pub api_base: String,
|
||||
pub app: String,
|
||||
pub callback_port: u16,
|
||||
pub scope: String,
|
||||
pub api_key_name: String,
|
||||
pub project_poll_interval_seconds: u64,
|
||||
pub project_poll_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for ApiProvisionOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
issuer: AUTH_ISSUER.to_string(),
|
||||
client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(),
|
||||
audience: PLATFORM_AUDIENCE.to_string(),
|
||||
api_base: DEFAULT_API_BASE.to_string(),
|
||||
app: DEFAULT_APP.to_string(),
|
||||
callback_port: DEFAULT_CALLBACK_PORT,
|
||||
scope: DEFAULT_SCOPE.to_string(),
|
||||
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
|
||||
project_poll_interval_seconds: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS,
|
||||
project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PendingApiProvisioning {
|
||||
client: Client,
|
||||
options: ApiProvisionOptions,
|
||||
redirect_uri: String,
|
||||
code_verifier: String,
|
||||
callback_server: AuthorizationCodeServer,
|
||||
}
|
||||
|
||||
impl PendingApiProvisioning {
|
||||
pub fn auth_url(&self) -> &str {
|
||||
&self.callback_server.auth_url
|
||||
}
|
||||
|
||||
pub fn callback_port(&self) -> u16 {
|
||||
self.callback_server.actual_port
|
||||
}
|
||||
|
||||
pub fn open_browser(&self) -> bool {
|
||||
self.callback_server.open_browser()
|
||||
}
|
||||
|
||||
pub fn open_browser_or_print(&self) -> bool {
|
||||
self.callback_server.open_browser_or_print()
|
||||
}
|
||||
|
||||
pub async fn finish(self) -> Result<ProvisionedApiKey, HelperError> {
|
||||
let code = self
|
||||
.callback_server
|
||||
.wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS))
|
||||
.await
|
||||
.map_err(|err| HelperError::message(err.to_string()))?;
|
||||
provision_from_authorization_code(
|
||||
&self.client,
|
||||
&self.options,
|
||||
&self.redirect_uri,
|
||||
&self.code_verifier,
|
||||
&code,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProvisionedApiKey {
|
||||
pub sensitive_id: String,
|
||||
pub organization_id: String,
|
||||
pub organization_title: Option<String>,
|
||||
pub default_project_id: String,
|
||||
pub default_project_title: Option<String>,
|
||||
pub project_api_key: String,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
pub fn start_api_provisioning(
|
||||
options: ApiProvisionOptions,
|
||||
) -> Result<PendingApiProvisioning, HelperError> {
|
||||
validate_api_provision_options(&options)?;
|
||||
let client = build_http_client()?;
|
||||
let callback_server = start_authorization_code_server(
|
||||
options.callback_port,
|
||||
DEFAULT_CALLBACK_PATH,
|
||||
/*force_state*/ None,
|
||||
|redirect_uri, pkce, state| {
|
||||
build_authorize_url(&options, redirect_uri, pkce, state)
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
},
|
||||
)
|
||||
.map_err(|err| HelperError::message(err.to_string()))?;
|
||||
let redirect_uri = callback_server.redirect_uri.clone();
|
||||
|
||||
Ok(PendingApiProvisioning {
|
||||
client,
|
||||
options,
|
||||
redirect_uri,
|
||||
code_verifier: callback_server.code_verifier().to_string(),
|
||||
callback_server,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run_from_env() -> Result<(), HelperError> {
|
||||
match parse_args(std::env::args())? {
|
||||
ParseOutcome::Help(help) => {
|
||||
println!("{help}");
|
||||
Ok(())
|
||||
}
|
||||
ParseOutcome::Run(options) => {
|
||||
let auth_path = resolve_codex_auth_path(options.codex_auth_path.as_deref())?;
|
||||
let session = start_api_provisioning(options.api_provision_options())?;
|
||||
session.open_browser_or_print();
|
||||
let provisioned = session.finish().await?;
|
||||
let codex_auth_synced = !options.skip_codex_auth_sync;
|
||||
if codex_auth_synced {
|
||||
sync_codex_api_key(&provisioned.project_api_key, &auth_path)?;
|
||||
eprintln!("Synced project API key to {}.", auth_path.display());
|
||||
} else {
|
||||
eprintln!("Skipping Codex auth sync.");
|
||||
}
|
||||
let output = ScriptOutput {
|
||||
sensitive_id: provisioned.sensitive_id,
|
||||
organization_id: provisioned.organization_id,
|
||||
organization_title: provisioned.organization_title,
|
||||
default_project_id: provisioned.default_project_id,
|
||||
default_project_title: provisioned.default_project_title,
|
||||
project_api_key: provisioned.project_api_key,
|
||||
codex_auth_path: auth_path.display().to_string(),
|
||||
codex_auth_synced,
|
||||
access_token: options
|
||||
.include_access_token
|
||||
.then_some(provisioned.access_token),
|
||||
};
|
||||
print_output(&output, options.output)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CliOptions {
|
||||
issuer: String,
|
||||
client_id: String,
|
||||
audience: String,
|
||||
api_base: String,
|
||||
app: String,
|
||||
callback_port: u16,
|
||||
scope: String,
|
||||
api_key_name: String,
|
||||
project_poll_interval_seconds: u64,
|
||||
project_poll_timeout_seconds: u64,
|
||||
codex_auth_path: Option<PathBuf>,
|
||||
skip_codex_auth_sync: bool,
|
||||
include_access_token: bool,
|
||||
output: OutputFormat,
|
||||
}
|
||||
|
||||
impl Default for CliOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
issuer: AUTH_ISSUER.to_string(),
|
||||
client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(),
|
||||
audience: PLATFORM_AUDIENCE.to_string(),
|
||||
api_base: DEFAULT_API_BASE.to_string(),
|
||||
app: DEFAULT_APP.to_string(),
|
||||
callback_port: DEFAULT_CALLBACK_PORT,
|
||||
scope: DEFAULT_SCOPE.to_string(),
|
||||
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
|
||||
project_poll_interval_seconds: DEFAULT_PROJECT_POLL_INTERVAL_SECONDS,
|
||||
project_poll_timeout_seconds: DEFAULT_PROJECT_POLL_TIMEOUT_SECONDS,
|
||||
codex_auth_path: None,
|
||||
skip_codex_auth_sync: false,
|
||||
include_access_token: false,
|
||||
output: OutputFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CliOptions {
|
||||
fn api_provision_options(&self) -> ApiProvisionOptions {
|
||||
ApiProvisionOptions {
|
||||
issuer: self.issuer.clone(),
|
||||
client_id: self.client_id.clone(),
|
||||
audience: self.audience.clone(),
|
||||
api_base: self.api_base.clone(),
|
||||
app: self.app.clone(),
|
||||
callback_port: self.callback_port,
|
||||
scope: self.scope.clone(),
|
||||
api_key_name: self.api_key_name.clone(),
|
||||
project_poll_interval_seconds: self.project_poll_interval_seconds,
|
||||
project_poll_timeout_seconds: self.project_poll_timeout_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OutputFormat {
|
||||
Json,
|
||||
SensitiveId,
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
fn parse(raw: &str) -> Result<Self, HelperError> {
|
||||
match raw {
|
||||
"json" => Ok(Self::Json),
|
||||
"sensitive_id" => Ok(Self::SensitiveId),
|
||||
"api_key" => Ok(Self::ApiKey),
|
||||
_ => Err(HelperError::message(format!(
|
||||
"invalid value for `--output`: `{raw}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ParseOutcome {
|
||||
Help(String),
|
||||
Run(CliOptions),
|
||||
}
|
||||
|
||||
fn parse_args<I>(args: I) -> Result<ParseOutcome, HelperError>
|
||||
where
|
||||
I: IntoIterator<Item = String>,
|
||||
{
|
||||
let mut args = args.into_iter();
|
||||
let program = args
|
||||
.next()
|
||||
.unwrap_or_else(|| "get_sensitive_id_via_codex_oauth".to_string());
|
||||
let mut options = CliOptions::default();
|
||||
let mut rest = args.peekable();
|
||||
while let Some(arg) = rest.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => return Ok(ParseOutcome::Help(usage(&program))),
|
||||
"--issuer" => options.issuer = take_value(&mut rest, "--issuer")?,
|
||||
"--client-id" => options.client_id = take_value(&mut rest, "--client-id")?,
|
||||
"--audience" => options.audience = take_value(&mut rest, "--audience")?,
|
||||
"--api-base" => options.api_base = take_value(&mut rest, "--api-base")?,
|
||||
"--app" => options.app = take_value(&mut rest, "--app")?,
|
||||
"--callback-port" => {
|
||||
options.callback_port =
|
||||
parse_u16(take_value(&mut rest, "--callback-port")?, "--callback-port")?
|
||||
}
|
||||
"--scope" => options.scope = take_value(&mut rest, "--scope")?,
|
||||
"--api-key-name" => options.api_key_name = take_value(&mut rest, "--api-key-name")?,
|
||||
"--project-poll-interval-seconds" => {
|
||||
options.project_poll_interval_seconds = parse_u64(
|
||||
take_value(&mut rest, "--project-poll-interval-seconds")?,
|
||||
"--project-poll-interval-seconds",
|
||||
)?
|
||||
}
|
||||
"--project-poll-timeout-seconds" => {
|
||||
options.project_poll_timeout_seconds = parse_u64(
|
||||
take_value(&mut rest, "--project-poll-timeout-seconds")?,
|
||||
"--project-poll-timeout-seconds",
|
||||
)?
|
||||
}
|
||||
"--codex-auth-path" => {
|
||||
options.codex_auth_path =
|
||||
Some(PathBuf::from(take_value(&mut rest, "--codex-auth-path")?))
|
||||
}
|
||||
"--skip-codex-auth-sync" => options.skip_codex_auth_sync = true,
|
||||
"--include-access-token" => options.include_access_token = true,
|
||||
"--output" => {
|
||||
options.output = OutputFormat::parse(&take_value(&mut rest, "--output")?)?
|
||||
}
|
||||
_ => {
|
||||
return Err(HelperError::message(format!(
|
||||
"unknown argument `{arg}`\n\n{}",
|
||||
usage(&program)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_api_provision_options(&options.api_provision_options())?;
|
||||
Ok(ParseOutcome::Run(options))
|
||||
}
|
||||
|
||||
fn validate_api_provision_options(options: &ApiProvisionOptions) -> Result<(), HelperError> {
|
||||
if options.project_poll_interval_seconds == 0 {
|
||||
return Err(HelperError::message(
|
||||
"--project-poll-interval-seconds must be greater than 0.".to_string(),
|
||||
));
|
||||
}
|
||||
if options.project_poll_timeout_seconds == 0 {
|
||||
return Err(HelperError::message(
|
||||
"--project-poll-timeout-seconds must be greater than 0.".to_string(),
|
||||
));
|
||||
}
|
||||
if options.api_key_name.trim().is_empty() {
|
||||
return Err(HelperError::message(
|
||||
"--api-key-name must not be empty.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn usage(program: &str) -> String {
|
||||
format!(
|
||||
"Usage: {program} [OPTIONS]\n\n\
|
||||
Options:\n\
|
||||
--issuer URL OAuth issuer base URL\n\
|
||||
--client-id ID Hydra client id to use\n\
|
||||
--audience URL OAuth audience\n\
|
||||
--api-base URL Base API URL for onboarding exchange\n\
|
||||
--app NAME `app` value for /dashboard/onboarding/login\n\
|
||||
--callback-port PORT Local callback port (default: {DEFAULT_CALLBACK_PORT})\n\
|
||||
--scope SCOPE OAuth scope string\n\
|
||||
--api-key-name NAME Provisioned project API key name\n\
|
||||
--project-poll-interval-seconds SEC Delay between default-project checks\n\
|
||||
--project-poll-timeout-seconds SEC Maximum wait for organization/project readiness\n\
|
||||
--codex-auth-path PATH Explicit auth.json path\n\
|
||||
--skip-codex-auth-sync Do not write the provisioned API key to auth.json\n\
|
||||
--include-access-token Include the OAuth access token in JSON output\n\
|
||||
--output FORMAT One of: json, sensitive_id, api_key\n\
|
||||
-h, --help Show this help message"
|
||||
)
|
||||
}
|
||||
|
||||
fn take_value<I>(args: &mut std::iter::Peekable<I>, flag: &str) -> Result<String, HelperError>
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
args.next()
|
||||
.ok_or_else(|| HelperError::message(format!("missing value for `{flag}`")))
|
||||
}
|
||||
|
||||
fn parse_u16(raw: String, flag: &str) -> Result<u16, HelperError> {
|
||||
raw.parse::<u16>()
|
||||
.map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}")))
|
||||
}
|
||||
|
||||
fn parse_u64(raw: String, flag: &str) -> Result<u64, HelperError> {
|
||||
raw.parse::<u64>()
|
||||
.map_err(|err| HelperError::message(format!("invalid value for `{flag}`: {err}")))
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
options: &ApiProvisionOptions,
|
||||
redirect_uri: &str,
|
||||
pkce: &PkceCodes,
|
||||
state: &str,
|
||||
) -> Result<String, HelperError> {
|
||||
let mut url = Url::parse(&format!(
|
||||
"{}/oauth/authorize",
|
||||
options.issuer.trim_end_matches('/')
|
||||
))
|
||||
.map_err(|err| HelperError::message(format!("invalid issuer URL: {err}")))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("audience", &options.audience)
|
||||
.append_pair("client_id", &options.client_id)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", &pkce.code_challenge)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", &options.scope)
|
||||
.append_pair("state", state);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn build_http_client() -> Result<Client, HelperError> {
|
||||
build_reqwest_client_with_custom_ca(
|
||||
reqwest::Client::builder().timeout(Duration::from_secs(HTTP_TIMEOUT_SECONDS)),
|
||||
)
|
||||
.map_err(|err| HelperError::message(format!("failed to build HTTP client: {err}")))
|
||||
}
|
||||
|
||||
async fn provision_from_authorization_code(
|
||||
client: &Client,
|
||||
options: &ApiProvisionOptions,
|
||||
redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
code: &str,
|
||||
) -> Result<ProvisionedApiKey, HelperError> {
|
||||
let tokens = exchange_authorization_code_for_tokens(
|
||||
client,
|
||||
&options.issuer,
|
||||
&options.client_id,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
code,
|
||||
)
|
||||
.await?;
|
||||
let login = onboarding_login(
|
||||
client,
|
||||
&options.api_base,
|
||||
&options.app,
|
||||
&tokens.access_token,
|
||||
)
|
||||
.await?;
|
||||
let target = wait_for_default_project(
|
||||
client,
|
||||
&options.api_base,
|
||||
&login.user.session.sensitive_id,
|
||||
options.project_poll_interval_seconds,
|
||||
options.project_poll_timeout_seconds,
|
||||
)
|
||||
.await?;
|
||||
let api_key = create_project_api_key(
|
||||
client,
|
||||
&options.api_base,
|
||||
&login.user.session.sensitive_id,
|
||||
&target,
|
||||
&options.api_key_name,
|
||||
)
|
||||
.await?
|
||||
.key
|
||||
.sensitive_id;
|
||||
|
||||
Ok(ProvisionedApiKey {
|
||||
sensitive_id: login.user.session.sensitive_id,
|
||||
organization_id: target.organization_id,
|
||||
organization_title: target.organization_title,
|
||||
default_project_id: target.project_id,
|
||||
default_project_title: target.project_title,
|
||||
project_api_key: api_key,
|
||||
access_token: tokens.access_token,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exchange_authorization_code_for_tokens(
|
||||
client: &Client,
|
||||
issuer: &str,
|
||||
client_id: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
code: &str,
|
||||
) -> Result<OAuthTokens, HelperError> {
|
||||
let url = format!("{}/oauth/token", issuer.trim_end_matches('/'));
|
||||
execute_json(
|
||||
client
|
||||
.request(Method::POST, &url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
|
||||
.json(&serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"code_verifier": code_verifier,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
})),
|
||||
"POST",
|
||||
&url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn onboarding_login(
|
||||
client: &Client,
|
||||
api_base: &str,
|
||||
app: &str,
|
||||
access_token: &str,
|
||||
) -> Result<OnboardingLoginResponse, HelperError> {
|
||||
let url = format!(
|
||||
"{}/dashboard/onboarding/login",
|
||||
api_base.trim_end_matches('/')
|
||||
);
|
||||
execute_json(
|
||||
client
|
||||
.request(Method::POST, &url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({ "app": app })),
|
||||
"POST",
|
||||
&url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_organizations(
|
||||
client: &Client,
|
||||
api_base: &str,
|
||||
session_key: &str,
|
||||
) -> Result<Vec<Organization>, HelperError> {
|
||||
let url = format!("{}/v1/organizations", api_base.trim_end_matches('/'));
|
||||
let response: DataList<Organization> = execute_json(
|
||||
client
|
||||
.request(Method::GET, &url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
|
||||
.bearer_auth(session_key),
|
||||
"GET",
|
||||
&url,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.data)
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
client: &Client,
|
||||
api_base: &str,
|
||||
session_key: &str,
|
||||
organization_id: &str,
|
||||
) -> Result<Vec<Project>, HelperError> {
|
||||
let url = format!(
|
||||
"{}/dashboard/organizations/{}/projects?detail=basic&limit=100",
|
||||
api_base.trim_end_matches('/'),
|
||||
urlencoding::encode(organization_id)
|
||||
);
|
||||
let response: DataList<Project> = execute_json(
|
||||
client
|
||||
.request(Method::GET, &url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
|
||||
.header("openai-organization", organization_id)
|
||||
.bearer_auth(session_key),
|
||||
"GET",
|
||||
&url,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.data)
|
||||
}
|
||||
|
||||
async fn wait_for_default_project(
|
||||
client: &Client,
|
||||
api_base: &str,
|
||||
session_key: &str,
|
||||
poll_interval_seconds: u64,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<ProvisioningTarget, HelperError> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds);
|
||||
loop {
|
||||
let organizations = list_organizations(client, api_base, session_key).await?;
|
||||
let last_state = if let Some(organization) = select_active_organization(&organizations) {
|
||||
let projects = list_projects(client, api_base, session_key, &organization.id).await?;
|
||||
if let Some(project) = find_default_project(&projects) {
|
||||
return Ok(ProvisioningTarget {
|
||||
organization_id: organization.id.clone(),
|
||||
organization_title: organization.title.clone(),
|
||||
project_id: project.id.clone(),
|
||||
project_title: project.title.clone(),
|
||||
});
|
||||
}
|
||||
format!(
|
||||
"organization `{}` exists, but no default project is ready yet (saw {} projects).",
|
||||
organization.id,
|
||||
projects.len()
|
||||
)
|
||||
} else {
|
||||
"no organization found".to_string()
|
||||
};
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(HelperError::message(format!(
|
||||
"Timed out waiting for an organization and default project. Last observed state: {last_state}"
|
||||
)));
|
||||
}
|
||||
let remaining_seconds = deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.as_secs();
|
||||
let sleep_seconds = poll_interval_seconds.min(remaining_seconds.max(1));
|
||||
std::thread::sleep(Duration::from_secs(sleep_seconds));
|
||||
}
|
||||
}
|
||||
|
||||
fn select_active_organization(organizations: &[Organization]) -> Option<&Organization> {
|
||||
organizations
|
||||
.iter()
|
||||
.find(|organization| organization.is_default)
|
||||
.or_else(|| {
|
||||
organizations
|
||||
.iter()
|
||||
.find(|organization| organization.personal)
|
||||
})
|
||||
.or_else(|| organizations.first())
|
||||
}
|
||||
|
||||
fn find_default_project(projects: &[Project]) -> Option<&Project> {
|
||||
projects.iter().find(|project| project.is_initial)
|
||||
}
|
||||
|
||||
async fn create_project_api_key(
|
||||
client: &Client,
|
||||
api_base: &str,
|
||||
session_key: &str,
|
||||
target: &ProvisioningTarget,
|
||||
key_name: &str,
|
||||
) -> Result<CreateApiKeyResponse, HelperError> {
|
||||
let url = format!(
|
||||
"{}/dashboard/organizations/{}/projects/{}/api_keys",
|
||||
api_base.trim_end_matches('/'),
|
||||
urlencoding::encode(&target.organization_id),
|
||||
urlencoding::encode(&target.project_id)
|
||||
);
|
||||
execute_json(
|
||||
client
|
||||
.request(Method::POST, &url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, DEFAULT_USER_AGENT)
|
||||
.bearer_auth(session_key)
|
||||
.json(&serde_json::json!({
|
||||
"action": "create",
|
||||
"name": key_name,
|
||||
})),
|
||||
"POST",
|
||||
&url,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_json<T>(
|
||||
request: reqwest::RequestBuilder,
|
||||
method: &str,
|
||||
url: &str,
|
||||
) -> Result<T, HelperError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| HelperError::message(format!("Network error calling {url}: {err}")))?;
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.map_err(|err| {
|
||||
HelperError::message(format!("Failed reading response from {url}: {err}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(HelperError::api(
|
||||
format!("{method} {url} failed with HTTP {status}"),
|
||||
String::from_utf8_lossy(&body).into_owned(),
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|err| HelperError::message(format!("{url} returned invalid JSON: {err}")))
|
||||
}
|
||||
|
||||
fn resolve_codex_auth_path(explicit: Option<&Path>) -> Result<PathBuf, HelperError> {
|
||||
match explicit {
|
||||
Some(path) => Ok(path.to_path_buf()),
|
||||
None => Ok(find_codex_home()
|
||||
.map_err(|err| HelperError::message(format!("failed to resolve CODEX_HOME: {err}")))?
|
||||
.join("auth.json")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_codex_api_key(api_key: &str, auth_path: &Path) -> Result<(), HelperError> {
|
||||
if let Some(parent) = auth_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|err| {
|
||||
HelperError::message(format!(
|
||||
"Failed to create auth directory {}: {err}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some(api_key.to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
};
|
||||
let json = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&auth).map_err(|err| {
|
||||
HelperError::message(format!("failed to serialize auth.json contents: {err}"))
|
||||
})?
|
||||
);
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(auth_path).map_err(|err| {
|
||||
HelperError::message(format!(
|
||||
"Failed to open {} for writing: {err}",
|
||||
auth_path.display()
|
||||
))
|
||||
})?;
|
||||
file.write_all(json.as_bytes()).map_err(|err| {
|
||||
HelperError::message(format!("Failed to write {}: {err}", auth_path.display()))
|
||||
})?;
|
||||
file.flush().map_err(|err| {
|
||||
HelperError::message(format!("Failed to flush {}: {err}", auth_path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
fn print_output(output: &ScriptOutput, format: OutputFormat) -> Result<(), HelperError> {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(output).map_err(|err| {
|
||||
HelperError::message(format!("failed to serialize output: {err}"))
|
||||
})?
|
||||
);
|
||||
}
|
||||
OutputFormat::SensitiveId => println!("{}", output.sensitive_id),
|
||||
OutputFormat::ApiKey => println!("{}", output.project_api_key),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct ScriptOutput {
|
||||
sensitive_id: String,
|
||||
organization_id: String,
|
||||
organization_title: Option<String>,
|
||||
default_project_id: String,
|
||||
default_project_title: Option<String>,
|
||||
project_api_key: String,
|
||||
codex_auth_path: String,
|
||||
codex_auth_synced: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
access_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OAuthTokens {
|
||||
#[serde(rename = "id_token")]
|
||||
_id_token: String,
|
||||
access_token: String,
|
||||
#[serde(rename = "refresh_token")]
|
||||
_refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OnboardingLoginResponse {
|
||||
user: OnboardingUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OnboardingUser {
|
||||
session: OnboardingSession,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OnboardingSession {
|
||||
sensitive_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DataList<T> {
|
||||
data: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
struct Organization {
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
is_default: bool,
|
||||
#[serde(default)]
|
||||
personal: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
struct Project {
|
||||
id: String,
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
is_initial: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProvisioningTarget {
|
||||
organization_id: String,
|
||||
organization_title: Option<String>,
|
||||
project_id: String,
|
||||
project_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateApiKeyResponse {
|
||||
key: CreatedApiKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreatedApiKey {
|
||||
sensitive_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HelperError {
|
||||
message: String,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
impl HelperError {
|
||||
fn message(message: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn api(message: String, body: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
body: Some(body),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn body(&self) -> Option<&str> {
|
||||
self.body.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HelperError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HelperError {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "onboard_oauth_helper_tests.rs"]
|
||||
mod tests;
|
||||
187
codex-rs/login/src/onboard_oauth_helper_tests.rs
Normal file
187
codex-rs/login/src/onboard_oauth_helper_tests.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
#[test]
|
||||
fn select_active_organization_prefers_default_then_personal_then_first() {
|
||||
let organizations = vec![
|
||||
Organization {
|
||||
id: "org-first".to_string(),
|
||||
title: Some("First".to_string()),
|
||||
is_default: false,
|
||||
personal: false,
|
||||
},
|
||||
Organization {
|
||||
id: "org-personal".to_string(),
|
||||
title: Some("Personal".to_string()),
|
||||
is_default: false,
|
||||
personal: true,
|
||||
},
|
||||
Organization {
|
||||
id: "org-default".to_string(),
|
||||
title: Some("Default".to_string()),
|
||||
is_default: true,
|
||||
personal: false,
|
||||
},
|
||||
];
|
||||
|
||||
let selected = select_active_organization(&organizations);
|
||||
|
||||
assert_eq!(selected, organizations.get(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_default_project_returns_initial_project() {
|
||||
let projects = vec![
|
||||
Project {
|
||||
id: "proj-secondary".to_string(),
|
||||
title: Some("Secondary".to_string()),
|
||||
is_initial: false,
|
||||
},
|
||||
Project {
|
||||
id: "proj-default".to_string(),
|
||||
title: Some("Default".to_string()),
|
||||
is_initial: true,
|
||||
},
|
||||
];
|
||||
|
||||
let selected = find_default_project(&projects);
|
||||
|
||||
assert_eq!(selected, projects.get(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_codex_api_key_writes_expected_auth_json() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let auth_path = temp_dir.path().join("codex").join("auth.json");
|
||||
|
||||
sync_codex_api_key("sk-test-key", &auth_path).expect("sync auth");
|
||||
|
||||
let written = std::fs::read_to_string(&auth_path).expect("read auth");
|
||||
let parsed: AuthDotJson = serde_json::from_str(&written).expect("parse auth");
|
||||
assert_eq!(
|
||||
parsed,
|
||||
AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provision_from_authorization_code_provisions_api_key() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.and(body_json(json!({
|
||||
"client_id": "client-123",
|
||||
"code_verifier": "verifier-123",
|
||||
"code": "auth-code-123",
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": "http://localhost:5000/auth/callback",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id_token": "id-token-123",
|
||||
"access_token": "oauth-access-123",
|
||||
"refresh_token": "oauth-refresh-123",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/dashboard/onboarding/login"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"user": {
|
||||
"session": {
|
||||
"sensitive_id": "session-123",
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/organizations"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": "org-default",
|
||||
"title": "Default Org",
|
||||
"is_default": true,
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/dashboard/organizations/org-default/projects"))
|
||||
.and(query_param("detail", "basic"))
|
||||
.and(query_param("limit", "100"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [
|
||||
{
|
||||
"id": "proj-default",
|
||||
"title": "Default Project",
|
||||
"is_initial": true,
|
||||
}
|
||||
]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path(
|
||||
"/dashboard/organizations/org-default/projects/proj-default/api_keys",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"key": {
|
||||
"sensitive_id": "sk-proj-123",
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let options = ApiProvisionOptions {
|
||||
issuer: server.uri(),
|
||||
client_id: "client-123".to_string(),
|
||||
audience: PLATFORM_AUDIENCE.to_string(),
|
||||
api_base: server.uri(),
|
||||
app: DEFAULT_APP.to_string(),
|
||||
callback_port: DEFAULT_CALLBACK_PORT,
|
||||
scope: DEFAULT_SCOPE.to_string(),
|
||||
api_key_name: DEFAULT_PROJECT_API_KEY_NAME.to_string(),
|
||||
project_poll_interval_seconds: 1,
|
||||
project_poll_timeout_seconds: 5,
|
||||
};
|
||||
let client = build_http_client().expect("client");
|
||||
|
||||
let output = provision_from_authorization_code(
|
||||
&client,
|
||||
&options,
|
||||
"http://localhost:5000/auth/callback",
|
||||
"verifier-123",
|
||||
"auth-code-123",
|
||||
)
|
||||
.await
|
||||
.expect("provision");
|
||||
|
||||
assert_eq!(
|
||||
output,
|
||||
ProvisionedApiKey {
|
||||
sensitive_id: "session-123".to_string(),
|
||||
organization_id: "org-default".to_string(),
|
||||
organization_title: Some("Default Org".to_string()),
|
||||
default_project_id: "proj-default".to_string(),
|
||||
default_project_title: Some("Default Project".to_string()),
|
||||
project_api_key: "sk-proj-123".to_string(),
|
||||
access_token: "oauth-access-123".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,67 @@ impl LoginServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for a running authorization-code callback server.
|
||||
pub(crate) struct AuthorizationCodeServer {
|
||||
pub auth_url: String,
|
||||
pub actual_port: u16,
|
||||
pub redirect_uri: String,
|
||||
code_verifier: String,
|
||||
server_handle: tokio::task::JoinHandle<io::Result<String>>,
|
||||
shutdown_handle: ShutdownHandle,
|
||||
}
|
||||
|
||||
impl AuthorizationCodeServer {
|
||||
pub fn open_browser(&self) -> bool {
|
||||
webbrowser::open(&self.auth_url).is_ok()
|
||||
}
|
||||
|
||||
pub fn open_browser_or_print(&self) -> bool {
|
||||
let opened = self.open_browser();
|
||||
if opened {
|
||||
eprintln!(
|
||||
"Starting local auth callback server.\nIf your browser did not open, navigate to this URL to continue:\n\n{}",
|
||||
self.auth_url
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Starting local auth callback server.\nOpen this URL in your browser to continue:\n\n{}",
|
||||
self.auth_url
|
||||
);
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
pub fn code_verifier(&self) -> &str {
|
||||
&self.code_verifier
|
||||
}
|
||||
|
||||
pub async fn wait_for_code(self, timeout: Duration) -> io::Result<String> {
|
||||
let AuthorizationCodeServer {
|
||||
server_handle,
|
||||
shutdown_handle,
|
||||
..
|
||||
} = self;
|
||||
let server_handle = server_handle;
|
||||
tokio::pin!(server_handle);
|
||||
|
||||
tokio::select! {
|
||||
result = &mut server_handle => {
|
||||
result
|
||||
.map_err(|err| io::Error::other(format!("authorization-code server thread panicked: {err:?}")))?
|
||||
}
|
||||
_ = tokio::time::sleep(timeout) => {
|
||||
shutdown_handle.shutdown();
|
||||
let _ = server_handle.await;
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"OAuth login timed out waiting for the browser callback.",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle used to signal the login server loop to exit.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShutdownHandle {
|
||||
@@ -123,6 +184,91 @@ impl ShutdownHandle {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_authorization_code_server<F>(
|
||||
port: u16,
|
||||
callback_path: &str,
|
||||
force_state: Option<String>,
|
||||
auth_url_builder: F,
|
||||
) -> io::Result<AuthorizationCodeServer>
|
||||
where
|
||||
F: FnOnce(&str, &PkceCodes, &str) -> io::Result<String>,
|
||||
{
|
||||
let pkce = generate_pkce();
|
||||
let state = force_state.unwrap_or_else(generate_state);
|
||||
let callback_path = callback_path.to_string();
|
||||
|
||||
let server = bind_server(port)?;
|
||||
let actual_port = match server.server_addr().to_ip() {
|
||||
Some(addr) => addr.port(),
|
||||
None => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
"Unable to determine the server port",
|
||||
));
|
||||
}
|
||||
};
|
||||
let server = Arc::new(server);
|
||||
|
||||
let redirect_uri = format!("http://localhost:{actual_port}{callback_path}");
|
||||
let auth_url = auth_url_builder(&redirect_uri, &pkce, &state)?;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Request>(16);
|
||||
let _server_handle = {
|
||||
let server = server.clone();
|
||||
thread::spawn(move || -> io::Result<()> {
|
||||
while let Ok(request) = server.recv() {
|
||||
match tx.blocking_send(request) {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
eprintln!("Failed to send request to channel: {error}");
|
||||
return Err(io::Error::other("Failed to send request to channel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
|
||||
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
|
||||
let server_handle = {
|
||||
let shutdown_notify = shutdown_notify.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_notify.notified() => {
|
||||
break Err(io::Error::other("Authentication was not completed"));
|
||||
}
|
||||
maybe_req = rx.recv() => {
|
||||
let Some(req) = maybe_req else {
|
||||
break Err(io::Error::other("Authentication was not completed"));
|
||||
};
|
||||
|
||||
let url_raw = req.url().to_string();
|
||||
let response =
|
||||
process_authorization_code_request(&url_raw, &callback_path, &state);
|
||||
|
||||
if let Some(result) = respond_to_request(req, response).await {
|
||||
break result;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
server.unblock();
|
||||
result
|
||||
})
|
||||
};
|
||||
|
||||
Ok(AuthorizationCodeServer {
|
||||
auth_url,
|
||||
actual_port,
|
||||
redirect_uri,
|
||||
code_verifier: pkce.code_verifier,
|
||||
server_handle,
|
||||
shutdown_handle: ShutdownHandle { shutdown_notify },
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a local callback server and returns the browser auth URL.
|
||||
pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let pkce = generate_pkce();
|
||||
@@ -191,30 +337,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
let response =
|
||||
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
|
||||
|
||||
let exit_result = match response {
|
||||
HandledRequest::Response(response) => {
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
|
||||
None
|
||||
}
|
||||
HandledRequest::ResponseAndExit {
|
||||
headers,
|
||||
body,
|
||||
result,
|
||||
} => {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
send_response_with_disconnect(req, headers, body)
|
||||
})
|
||||
.await;
|
||||
Some(result)
|
||||
}
|
||||
HandledRequest::RedirectWithHeader(header) => {
|
||||
let redirect = Response::empty(302).with_header(header);
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(result) = exit_result {
|
||||
if let Some(result) = respond_to_request(req, response).await {
|
||||
break result;
|
||||
}
|
||||
}
|
||||
@@ -237,13 +360,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
|
||||
}
|
||||
|
||||
/// Internal callback handling outcome.
|
||||
enum HandledRequest {
|
||||
enum HandledRequest<T> {
|
||||
Response(Response<Cursor<Vec<u8>>>),
|
||||
RedirectWithHeader(Header),
|
||||
ResponseAndExit {
|
||||
status: StatusCode,
|
||||
headers: Vec<Header>,
|
||||
body: Vec<u8>,
|
||||
result: io::Result<()>,
|
||||
result: io::Result<T>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -254,7 +378,7 @@ async fn process_request(
|
||||
pkce: &PkceCodes,
|
||||
actual_port: u16,
|
||||
state: &str,
|
||||
) -> HandledRequest {
|
||||
) -> HandledRequest<()> {
|
||||
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
@@ -392,6 +516,7 @@ async fn process_request(
|
||||
"/success" => {
|
||||
let body = include_str!("assets/success.html");
|
||||
HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(200),
|
||||
headers: match Header::from_bytes(
|
||||
&b"Content-Type"[..],
|
||||
&b"text/html; charset=utf-8"[..],
|
||||
@@ -404,6 +529,7 @@ async fn process_request(
|
||||
}
|
||||
}
|
||||
"/cancel" => HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(200),
|
||||
headers: Vec::new(),
|
||||
body: b"Login cancelled".to_vec(),
|
||||
result: Err(io::Error::new(
|
||||
@@ -415,6 +541,117 @@ async fn process_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn process_authorization_code_request(
|
||||
url_raw: &str,
|
||||
callback_path: &str,
|
||||
expected_state: &str,
|
||||
) -> HandledRequest<String> {
|
||||
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
|
||||
Ok(u) => u,
|
||||
Err(err) => {
|
||||
return HandledRequest::Response(
|
||||
Response::from_string(format!("Bad Request: {err}")).with_status_code(400),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match parsed_url.path() {
|
||||
"/cancel" => HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(200),
|
||||
headers: Vec::new(),
|
||||
body: b"Login cancelled".to_vec(),
|
||||
result: Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"Login cancelled",
|
||||
)),
|
||||
},
|
||||
path if path == callback_path => {
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
parsed_url.query_pairs().into_owned().collect();
|
||||
|
||||
if params.get("state").map(String::as_str) != Some(expected_state) {
|
||||
return HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(400),
|
||||
headers: html_headers(),
|
||||
body: b"<h1>State mismatch</h1><p>Return to your terminal and try again.</p>"
|
||||
.to_vec(),
|
||||
result: Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"State mismatch in OAuth callback.",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(error_code) = params.get("error") {
|
||||
let message = oauth_callback_error_message(
|
||||
error_code,
|
||||
params.get("error_description").map(String::as_str),
|
||||
);
|
||||
return HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(403),
|
||||
headers: html_headers(),
|
||||
body: b"<h1>Sign-in failed</h1><p>Return to your terminal.</p>".to_vec(),
|
||||
result: Err(io::Error::new(io::ErrorKind::PermissionDenied, message)),
|
||||
};
|
||||
}
|
||||
|
||||
match params.get("code") {
|
||||
Some(code) if !code.is_empty() => HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(200),
|
||||
headers: html_headers(),
|
||||
body: b"<h1>Sign-in complete</h1><p>You can return to your terminal.</p>"
|
||||
.to_vec(),
|
||||
result: Ok(code.clone()),
|
||||
},
|
||||
_ => HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(400),
|
||||
headers: html_headers(),
|
||||
body: b"<h1>Missing authorization code</h1><p>Return to your terminal.</p>"
|
||||
.to_vec(),
|
||||
result: Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Missing authorization code. Sign-in could not be completed.",
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
|
||||
}
|
||||
}
|
||||
|
||||
fn html_headers() -> Vec<Header> {
|
||||
match Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
|
||||
Ok(header) => vec![header],
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond_to_request<T>(req: Request, response: HandledRequest<T>) -> Option<io::Result<T>> {
|
||||
match response {
|
||||
HandledRequest::Response(response) => {
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
|
||||
None
|
||||
}
|
||||
HandledRequest::RedirectWithHeader(header) => {
|
||||
let redirect = Response::empty(302).with_header(header);
|
||||
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
|
||||
None
|
||||
}
|
||||
HandledRequest::ResponseAndExit {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
result,
|
||||
} => {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
send_response_with_disconnect(req, status, headers, body)
|
||||
})
|
||||
.await;
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tiny_http filters `Connection` headers out of `Response` objects, so using
|
||||
/// `req.respond` never informs the client (or the library) that a keep-alive
|
||||
/// socket should be closed. That leaves the per-connection worker parked in a
|
||||
@@ -426,10 +663,10 @@ async fn process_request(
|
||||
/// server-side connection persistence, but it does not.
|
||||
fn send_response_with_disconnect(
|
||||
req: Request,
|
||||
status: StatusCode,
|
||||
mut headers: Vec<Header>,
|
||||
body: Vec<u8>,
|
||||
) -> io::Result<()> {
|
||||
let status = StatusCode(200);
|
||||
let mut writer = req.into_writer();
|
||||
let reason = status.default_reason_phrase();
|
||||
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
|
||||
@@ -888,13 +1125,14 @@ fn login_error_response(
|
||||
kind: io::ErrorKind,
|
||||
error_code: Option<&str>,
|
||||
error_description: Option<&str>,
|
||||
) -> HandledRequest {
|
||||
) -> HandledRequest<()> {
|
||||
let mut headers = Vec::new();
|
||||
if let Ok(header) = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]) {
|
||||
headers.push(header);
|
||||
}
|
||||
let body = render_login_error_page(message, error_code, error_description);
|
||||
HandledRequest::ResponseAndExit {
|
||||
status: StatusCode(200),
|
||||
headers,
|
||||
body,
|
||||
result: Err(io::Error::new(kind, message.to_string())),
|
||||
|
||||
321
codex-rs/tui/src/api_provision.rs
Normal file
321
codex-rs/tui/src/api_provision.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::read_openai_api_key_from_env;
|
||||
use codex_login::ApiProvisionOptions;
|
||||
use codex_login::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_login::PendingApiProvisioning;
|
||||
use codex_login::ProvisionedApiKey;
|
||||
use codex_login::start_api_provisioning;
|
||||
use codex_login::upsert_dotenv_api_key;
|
||||
use codex_login::validate_dotenv_target;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
|
||||
pub(crate) fn start_command(
|
||||
app_event_tx: AppEventSender,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
codex_home: PathBuf,
|
||||
cwd: PathBuf,
|
||||
forced_login_method: Option<ForcedLoginMethod>,
|
||||
) -> Result<PlainHistoryCell, String> {
|
||||
if read_openai_api_key_from_env().is_some() {
|
||||
return Ok(existing_shell_api_key_message());
|
||||
}
|
||||
|
||||
let dotenv_path = cwd.join(".env.local");
|
||||
validate_dotenv_target(&dotenv_path).map_err(|err| {
|
||||
format!(
|
||||
"Unable to prepare {} for {OPENAI_API_KEY_ENV_VAR}: {err}",
|
||||
dotenv_path.display(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let options = ApiProvisionOptions::default();
|
||||
let session = start_api_provisioning(options)
|
||||
.map_err(|err| format!("Failed to start API provisioning: {err}"))?;
|
||||
let browser_opened = session.open_browser();
|
||||
let start_message = continue_in_browser_message(
|
||||
session.auth_url(),
|
||||
session.callback_port(),
|
||||
&dotenv_path,
|
||||
browser_opened,
|
||||
);
|
||||
|
||||
let app_event_tx_for_task = app_event_tx;
|
||||
let dotenv_path_for_task = dotenv_path;
|
||||
tokio::spawn(async move {
|
||||
let cell = complete_command(
|
||||
session,
|
||||
dotenv_path_for_task,
|
||||
codex_home,
|
||||
forced_login_method,
|
||||
auth_manager,
|
||||
)
|
||||
.await;
|
||||
app_event_tx_for_task.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
});
|
||||
|
||||
Ok(start_message)
|
||||
}
|
||||
|
||||
fn existing_shell_api_key_message() -> PlainHistoryCell {
|
||||
history_cell::new_info_event(
|
||||
format!(
|
||||
"{OPENAI_API_KEY_ENV_VAR} is already set in this Codex session; skipping API provisioning."
|
||||
),
|
||||
Some(format!(
|
||||
"This Codex session already inherited {OPENAI_API_KEY_ENV_VAR} from its shell environment. Unset it and run /api-provision again if you want Codex to provision and save a different key."
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn continue_in_browser_message(
|
||||
auth_url: &str,
|
||||
callback_port: u16,
|
||||
dotenv_path: &Path,
|
||||
browser_opened: bool,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines = vec![
|
||||
vec![
|
||||
"• ".dim(),
|
||||
"Finish API provisioning via your browser.".into(),
|
||||
]
|
||||
.into(),
|
||||
"".into(),
|
||||
];
|
||||
|
||||
if browser_opened {
|
||||
lines.push(
|
||||
" Codex tried to open this link for you."
|
||||
.dark_gray()
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
" Codex couldn't auto-open your browser, but the provisioning flow is still waiting."
|
||||
.dark_gray()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
lines.push("".into());
|
||||
lines.push(" Open the following link to authenticate:".into());
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
auth_url.to_string().cyan().underlined(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
format!(
|
||||
" Codex will save {OPENAI_API_KEY_ENV_VAR} to {} and hot-apply it here when allowed.",
|
||||
dotenv_path.display()
|
||||
)
|
||||
.dark_gray()
|
||||
.into(),
|
||||
);
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
format!(
|
||||
" On a remote or headless machine, forward localhost:{callback_port} back to this Codex host before opening the link."
|
||||
)
|
||||
.dark_gray()
|
||||
.into(),
|
||||
);
|
||||
|
||||
PlainHistoryCell::new(lines)
|
||||
}
|
||||
|
||||
async fn complete_command(
|
||||
session: PendingApiProvisioning,
|
||||
dotenv_path: PathBuf,
|
||||
codex_home: PathBuf,
|
||||
forced_login_method: Option<ForcedLoginMethod>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> PlainHistoryCell {
|
||||
let provisioned = match session.finish().await {
|
||||
Ok(provisioned) => provisioned,
|
||||
Err(err) => {
|
||||
return history_cell::new_error_event(format!("API provisioning failed: {err}"));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = upsert_dotenv_api_key(&dotenv_path, &provisioned.project_api_key) {
|
||||
return history_cell::new_error_event(format!(
|
||||
"Provisioning completed, but Codex could not update {}: {err}",
|
||||
dotenv_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
success_cell(
|
||||
&provisioned,
|
||||
&dotenv_path,
|
||||
live_apply_api_key(
|
||||
forced_login_method,
|
||||
&codex_home,
|
||||
&provisioned.project_api_key,
|
||||
auth_manager,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn live_apply_api_key(
|
||||
forced_login_method: Option<ForcedLoginMethod>,
|
||||
codex_home: &Path,
|
||||
api_key: &str,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> LiveApplyOutcome {
|
||||
if matches!(forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
|
||||
return LiveApplyOutcome::Skipped(format!(
|
||||
"Saved {OPENAI_API_KEY_ENV_VAR} to .env.local, but left this session unchanged because ChatGPT login is required here."
|
||||
));
|
||||
}
|
||||
|
||||
match login_with_api_key(codex_home, api_key, AuthCredentialsStoreMode::Ephemeral) {
|
||||
Ok(()) => {
|
||||
auth_manager.reload();
|
||||
LiveApplyOutcome::Applied
|
||||
}
|
||||
Err(err) => LiveApplyOutcome::Failed(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn success_cell(
|
||||
provisioned: &ProvisionedApiKey,
|
||||
dotenv_path: &Path,
|
||||
live_apply_outcome: LiveApplyOutcome,
|
||||
) -> PlainHistoryCell {
|
||||
let organization = provisioned
|
||||
.organization_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| provisioned.organization_id.clone());
|
||||
let project = provisioned
|
||||
.default_project_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| provisioned.default_project_id.clone());
|
||||
let hint = match live_apply_outcome {
|
||||
LiveApplyOutcome::Applied => Some(
|
||||
"Updated this session to use the newly provisioned API key without touching auth.json."
|
||||
.to_string(),
|
||||
),
|
||||
LiveApplyOutcome::Skipped(reason) => Some(reason),
|
||||
LiveApplyOutcome::Failed(err) => Some(format!(
|
||||
"Saved {OPENAI_API_KEY_ENV_VAR} to {}, but could not hot-apply it in this session: {err}",
|
||||
dotenv_path.display(),
|
||||
)),
|
||||
};
|
||||
|
||||
history_cell::new_info_event(
|
||||
format!(
|
||||
"Provisioned an API key for {organization} / {project} and saved {OPENAI_API_KEY_ENV_VAR} to {}.",
|
||||
dotenv_path.display()
|
||||
),
|
||||
hint,
|
||||
)
|
||||
}
|
||||
|
||||
enum LiveApplyOutcome {
|
||||
Applied,
|
||||
Skipped(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn success_cell_snapshot() {
|
||||
let cell = success_cell(
|
||||
&ProvisionedApiKey {
|
||||
sensitive_id: "session-123".to_string(),
|
||||
organization_id: "org-default".to_string(),
|
||||
organization_title: Some("Default Org".to_string()),
|
||||
default_project_id: "proj-default".to_string(),
|
||||
default_project_title: Some("Default Project".to_string()),
|
||||
project_api_key: "sk-proj-123".to_string(),
|
||||
access_token: "oauth-access-123".to_string(),
|
||||
},
|
||||
Path::new("/tmp/workspace/.env.local"),
|
||||
LiveApplyOutcome::Applied,
|
||||
);
|
||||
|
||||
assert_snapshot!(render_cell(&cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_cell_snapshot_when_live_apply_is_skipped() {
|
||||
let cell = success_cell(
|
||||
&ProvisionedApiKey {
|
||||
sensitive_id: "session-123".to_string(),
|
||||
organization_id: "org-default".to_string(),
|
||||
organization_title: None,
|
||||
default_project_id: "proj-default".to_string(),
|
||||
default_project_title: None,
|
||||
project_api_key: "sk-proj-123".to_string(),
|
||||
access_token: "oauth-access-123".to_string(),
|
||||
},
|
||||
Path::new("/tmp/workspace/.env.local"),
|
||||
LiveApplyOutcome::Skipped(
|
||||
"Saved OPENAI_API_KEY to .env.local, but left this session unchanged because ChatGPT login is required here."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
|
||||
assert_snapshot!(render_cell(&cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_in_browser_message_snapshot() {
|
||||
let cell = continue_in_browser_message(
|
||||
"https://auth.openai.com/oauth/authorize?client_id=abc",
|
||||
/*callback_port*/ 5000,
|
||||
Path::new("/tmp/workspace/.env.local"),
|
||||
/*browser_opened*/ false,
|
||||
);
|
||||
|
||||
assert_snapshot!(render_cell(&cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_shell_api_key_message_mentions_openai_api_key() {
|
||||
let cell = existing_shell_api_key_message();
|
||||
|
||||
assert_eq!(
|
||||
render_cell(&cell),
|
||||
"• OPENAI_API_KEY is already set in this Codex session; skipping API provisioning. This Codex session already inherited OPENAI_API_KEY from its shell environment. Unset it and run /api-provision again if you want Codex to provision and save a different key."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_in_browser_message_always_includes_the_auth_url() {
|
||||
let cell = continue_in_browser_message(
|
||||
"https://auth.example.com/oauth/authorize?state=abc",
|
||||
5000,
|
||||
Path::new("/tmp/workspace/.env.local"),
|
||||
/*browser_opened*/ false,
|
||||
);
|
||||
|
||||
assert!(render_cell(&cell).contains("https://auth.example.com/oauth/authorize?state=abc"));
|
||||
}
|
||||
|
||||
fn render_cell(cell: &PlainHistoryCell) -> String {
|
||||
cell.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
@@ -4716,6 +4716,24 @@ impl ChatWidget {
|
||||
tx.send(AppEvent::DiffResult(text));
|
||||
});
|
||||
}
|
||||
SlashCommand::ApiProvision => {
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
match crate::api_provision::start_command(
|
||||
self.app_event_tx.clone(),
|
||||
self.auth_manager.clone(),
|
||||
self.config.codex_home.clone(),
|
||||
cwd,
|
||||
self.config.forced_login_method,
|
||||
) {
|
||||
Ok(start_message) => {
|
||||
self.add_boxed_history(Box::new(start_message));
|
||||
self.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_error_message(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
SlashCommand::Copy => {
|
||||
let Some(text) = self.last_copyable_output.as_deref() else {
|
||||
self.add_info_message(
|
||||
|
||||
@@ -325,7 +325,7 @@ impl ChatWidget {
|
||||
})
|
||||
}
|
||||
|
||||
fn status_line_cwd(&self) -> &Path {
|
||||
pub(super) fn status_line_cwd(&self) -> &Path {
|
||||
self.current_cwd.as_ref().unwrap_or(&self.config.cwd)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ use tracing_subscriber::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod additional_dirs;
|
||||
mod api_provision;
|
||||
mod app;
|
||||
mod app_backtrack;
|
||||
mod app_event;
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum SlashCommand {
|
||||
Agent,
|
||||
// Undo,
|
||||
Diff,
|
||||
ApiProvision,
|
||||
Copy,
|
||||
Mention,
|
||||
Status,
|
||||
@@ -82,6 +83,7 @@ impl SlashCommand {
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::ApiProvision => "provision an API key and save it to .env.local",
|
||||
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
@@ -155,6 +157,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::ApiProvision
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::MemoryDrop
|
||||
@@ -220,4 +223,15 @@ mod tests {
|
||||
fn clean_alias_parses_to_stop_command() {
|
||||
assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_provision_command_metadata() {
|
||||
assert_eq!(
|
||||
SlashCommand::from_str("api-provision"),
|
||||
Ok(SlashCommand::ApiProvision)
|
||||
);
|
||||
assert_eq!(SlashCommand::ApiProvision.command(), "api-provision");
|
||||
assert!(!SlashCommand::ApiProvision.supports_inline_args());
|
||||
assert!(!SlashCommand::ApiProvision.available_during_task());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/api_provision.rs
|
||||
expression: render_cell(&cell)
|
||||
---
|
||||
• Finish API provisioning via your browser.
|
||||
|
||||
Codex couldn't auto-open your browser, but the provisioning flow is still waiting.
|
||||
|
||||
Open the following link to authenticate:
|
||||
|
||||
https://auth.openai.com/oauth/authorize?client_id=abc
|
||||
|
||||
Codex will save OPENAI_API_KEY to /tmp/workspace/.env.local and hot-apply it here when allowed.
|
||||
|
||||
On a remote or headless machine, forward localhost:5000 back to this Codex host before opening the link.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/api_provision.rs
|
||||
assertion_line: 192
|
||||
expression: render_cell(&cell)
|
||||
---
|
||||
• Provisioned an API key for Default Org / Default Project and saved OPENAI_API_KEY to /tmp/workspace/.env.local. Updated this session to use the newly provisioned API key without touching auth.json.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/api_provision.rs
|
||||
assertion_line: 214
|
||||
expression: render_cell(&cell)
|
||||
---
|
||||
• Provisioned an API key for org-default / proj-default and saved OPENAI_API_KEY to /tmp/workspace/.env.local. Saved OPENAI_API_KEY to .env.local, but left this session unchanged because ChatGPT login is required here.
|
||||
Reference in New Issue
Block a user