mirror of
https://github.com/openai/codex.git
synced 2026-03-24 17:16:30 +03:00
Compare commits
2 Commits
eternal/ho
...
codex/api-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baafbe7026 | ||
|
|
6311965bc0 |
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 codex_core::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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod device_code_auth;
|
||||
mod dotenv_api_key;
|
||||
mod onboard_oauth_helper;
|
||||
mod pkce;
|
||||
mod server;
|
||||
|
||||
@@ -24,3 +26,11 @@ pub use codex_core::auth::login_with_api_key;
|
||||
pub use codex_core::auth::logout;
|
||||
pub use codex_core::auth::save_auth;
|
||||
pub use codex_core::token_data::TokenData;
|
||||
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;
|
||||
|
||||
1063
codex-rs/login/src/onboard_oauth_helper.rs
Normal file
1063
codex-rs/login/src/onboard_oauth_helper.rs
Normal file
File diff suppressed because it is too large
Load Diff
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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
255
codex-rs/tui/src/api_provision.rs
Normal file
255
codex-rs/tui/src/api_provision.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
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 crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
|
||||
pub(crate) struct ApiProvisionStartMessage {
|
||||
pub(crate) message: String,
|
||||
pub(crate) hint: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn start_command(
|
||||
app_event_tx: AppEventSender,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
codex_home: PathBuf,
|
||||
cwd: PathBuf,
|
||||
forced_login_method: Option<ForcedLoginMethod>,
|
||||
) -> Result<ApiProvisionStartMessage, String> {
|
||||
if read_openai_api_key_from_env().is_some() {
|
||||
return Ok(existing_shell_api_key_message());
|
||||
}
|
||||
|
||||
let dotenv_path = cwd.join(".env");
|
||||
let start_hint = format!(
|
||||
"Codex will save {OPENAI_API_KEY_ENV_VAR} to {path} and hot-apply it here when allowed.",
|
||||
path = dotenv_path.display()
|
||||
);
|
||||
validate_dotenv_target(&dotenv_path).map_err(|err| {
|
||||
format!(
|
||||
"Unable to prepare {} for {OPENAI_API_KEY_ENV_VAR}: {err}",
|
||||
dotenv_path.display(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let session = start_api_provisioning(ApiProvisionOptions::default())
|
||||
.map_err(|err| format!("Failed to start API provisioning: {err}"))?;
|
||||
if !session.open_browser() {
|
||||
return Err(
|
||||
"Failed to open your browser for API provisioning. Try again from a desktop session or use the helper binary."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
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(ApiProvisionStartMessage {
|
||||
message: "Opening your browser to provision a project API key.".to_string(),
|
||||
hint: Some(start_hint),
|
||||
})
|
||||
}
|
||||
|
||||
fn existing_shell_api_key_message() -> ApiProvisionStartMessage {
|
||||
ApiProvisionStartMessage {
|
||||
message: format!(
|
||||
"{OPENAI_API_KEY_ENV_VAR} is already set in this Codex session; skipping API provisioning."
|
||||
),
|
||||
hint: 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."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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, 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"),
|
||||
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"),
|
||||
LiveApplyOutcome::Skipped(
|
||||
"Saved OPENAI_API_KEY to .env, but left this session unchanged because ChatGPT login is required here."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
|
||||
assert_snapshot!(render_cell(&cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_shell_api_key_message_mentions_openai_api_key() {
|
||||
let message = existing_shell_api_key_message();
|
||||
|
||||
assert_eq!(
|
||||
message.message,
|
||||
"OPENAI_API_KEY is already set in this Codex session; skipping API provisioning."
|
||||
);
|
||||
assert_eq!(
|
||||
message.hint,
|
||||
Some(
|
||||
"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.".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn render_cell(cell: &PlainHistoryCell) -> String {
|
||||
cell.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
@@ -4497,6 +4497,23 @@ 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_info_message(start_message.message, start_message.hint);
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_error_message(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
SlashCommand::Copy => {
|
||||
let Some(text) = self.last_copyable_output.as_deref() else {
|
||||
self.add_info_message(
|
||||
|
||||
@@ -66,6 +66,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,
|
||||
@@ -80,6 +81,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",
|
||||
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",
|
||||
@@ -151,6 +153,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::ApiProvision
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::MemoryDrop
|
||||
@@ -214,4 +217,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,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. 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. Saved OPENAI_API_KEY to .env, but left this session unchanged because ChatGPT login is required here.
|
||||
Reference in New Issue
Block a user