feat(core/auth): prefer ChatGPT over API key when tokens exist

- Default  and conversation startup to
- Honor explicit API-key preference only when requested

feat(arg0/env): ignore provider keys in project .env

- Load ~/.code/.env first
- Always ignore OPENAI_API_KEY/AZURE_OPENAI_API_KEY in local .env

feat(tui): show bold "Auth: API key" badge when not using ChatGPT

feat(tui/onboarding): set Never + DangerFullAccess on trust

docs(auth): document .env safety and UI indicator

refactor(core/token_data): remove API-key plan heuristics (unused)
This commit is contained in:
James Peter
2025-09-09 00:50:17 +10:00
parent 6dc3bb49ef
commit a8cd8abdc0
9 changed files with 70 additions and 49 deletions
+16 -1
View File
@@ -111,14 +111,29 @@ const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
fn load_dotenv() {
// 1) Load from global ~/.code/.env (or ~/.codex/.env) first.
if let Ok(codex_home) = codex_core::config::find_codex_home() {
if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) {
// Global env may legitimately contain provider keys for Code usage.
set_filtered(iter);
}
}
// 2) Load from the current project's .env, but with extra safety:
// - Do NOT import provider API keys by default (e.g., OPENAI_API_KEY, AZURE_OPENAI_API_KEY).
// - Users can opt back in via CODEX_ALLOW_PROJECT_OPENAI_KEYS=1 (either exported
// in the shell or placed in ~/.code/.env).
if let Ok(iter) = dotenvy::dotenv_iter() {
set_filtered(iter);
// Filtered setter that always blocks provider keys from the project's .env.
for (key, value) in iter.into_iter().flatten() {
let upper = key.to_ascii_uppercase();
// Never allow CODEX_* to be set from .env files for safety.
if upper.starts_with(ILLEGAL_ENV_VAR_PREFIX) { continue; }
// Always ignore provider keys from project .env (must be set globally or in shell).
if upper == "OPENAI_API_KEY" || upper == "AZURE_OPENAI_API_KEY" { continue; }
// Safe: still single-threaded during startup.
unsafe { std::env::set_var(&key, &value) };
}
}
}
+7 -6
View File
@@ -262,19 +262,20 @@ fn load_auth(
last_refresh,
} = auth_dot_json;
// If the auth.json has an API key AND does not appear to be on a plan that
// should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
// If the auth.json has an API key, decide whether to use it.
if let Some(api_key) = &auth_json_api_key {
// Should any of these be AuthMode::ChatGPT with the api_key set?
// Does AuthMode::ChatGPT indicate that there is an auth.json that is
// "refreshable" even if we are using the API key for auth?
match &tokens {
Some(tokens) => {
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
Some(_tokens) => {
// When tokens are present, honor the caller's preference strictly:
// - If the caller prefers API key, use it.
// - Otherwise, prefer ChatGPT and ignore the API key.
if preferred_auth_method == AuthMode::ApiKey {
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
} else {
// Ignore the API key and fall through to ChatGPT auth.
}
// else: fall through to ChatGPT auth
}
None => {
// We have an API key but no tokens in the auth.json file.
+2 -1
View File
@@ -1232,7 +1232,8 @@ impl Config {
use codex_protocol::mcp_protocol::AuthMode;
use crate::CodexAuth;
match CodexAuth::from_codex_home(codex_home, AuthMode::ApiKey, "codex_cli_rs") {
// Prefer ChatGPT when both ChatGPT tokens and an API key are present.
match CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT, "codex_cli_rs") {
Ok(Some(auth)) => auth.mode == AuthMode::ChatGPT,
_ => false,
}
+2 -2
View File
@@ -67,10 +67,10 @@ impl ConversationManager {
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
// Build auth from codex_home using API key mode by default.
// Build auth from codex_home preferring ChatGPT by default.
let auth = CodexAuth::from_codex_home(
&config.codex_home,
AuthMode::ApiKey,
AuthMode::ChatGPT,
&config.responses_originator_header,
)?;
self.spawn_conversation(config, auth).await
-35
View File
@@ -3,7 +3,6 @@ use serde::Deserialize;
use serde::Serialize;
use thiserror::Error;
use codex_protocol::mcp_protocol::AuthMode;
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
pub struct TokenData {
@@ -23,27 +22,6 @@ pub struct TokenData {
}
impl TokenData {
/// Returns true if this is a plan that should use the traditional
/// "metered" billing via an API key.
pub(crate) fn should_use_api_key(
&self,
preferred_auth_method: AuthMode,
is_openai_email: bool,
) -> bool {
if preferred_auth_method == AuthMode::ApiKey {
return true;
}
// If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey.
if is_openai_email {
return false;
}
self.id_token
.chatgpt_plan_type
.as_ref()
.is_none_or(|plan| plan.is_plan_that_should_use_api_key())
}
pub fn is_openai_email(&self) -> bool {
self.id_token
.email
@@ -80,19 +58,6 @@ pub(crate) enum PlanType {
}
impl PlanType {
fn is_plan_that_should_use_api_key(&self) -> bool {
match self {
Self::Known(known) => {
use KnownPlan::*;
!matches!(known, Free | Plus | Pro | Team)
}
Self::Unknown(_) => {
// Unknown plans should use the API key.
true
}
}
}
pub fn as_string(&self) -> String {
match self {
Self::Known(known) => format!("{known:?}").to_lowercase(),
@@ -1501,6 +1501,15 @@ impl WidgetRef for ChatComposer {
}
}
// Show current auth mode. Make API key usage obvious.
if !self.using_chatgpt_auth {
if !left_spans.is_empty() { left_spans.push(Span::from(" ")); }
left_spans.push(
Span::from("Auth: API key")
.style(Style::default().fg(crate::colors::warning()).add_modifier(Modifier::BOLD)),
);
}
// Right side: command key hints (Ctrl+R/D/H) followed by token usage if available
// We will elide hints when space is tight in this order: hide reasoning, diff viewer.
let mut right_spans: Vec<Span> = Vec::new();
+1 -1
View File
@@ -441,7 +441,7 @@ pub enum LoginStatus {
/// Determine current login status based on auth.json presence.
pub fn get_login_status(config: &Config) -> LoginStatus {
let codex_home = config.codex_home.clone();
match CodexAuth::from_codex_home(&codex_home, AuthMode::ApiKey, &config.responses_originator_header) {
match CodexAuth::from_codex_home(&codex_home, AuthMode::ChatGPT, &config.responses_originator_header) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
_ => LoginStatus::NotAuthenticated,
}
+16 -3
View File
@@ -1,6 +1,8 @@
use std::path::PathBuf;
use codex_core::config::set_project_trusted;
use codex_core::config::set_project_access_mode;
use codex_protocol::config_types::SandboxMode as SandboxModeCfg;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
@@ -166,10 +168,21 @@ impl TrustDirectoryWidget {
}
// Update the in-memory chat config for this session to a more permissive
// policy suitable for a trusted workspace.
// policy suitable for a trusted workspace (Full Access).
if let Ok(mut args) = self.chat_widget_args.lock() {
args.config.approval_policy = AskForApproval::OnRequest;
args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
args.config.approval_policy = AskForApproval::Never;
args.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
}
// Persist the access mode explicitly so subsequent runs don't rely solely on
// the trust fallback logic and so the UI reflects the chosen mode immediately.
if let Err(e) = set_project_access_mode(
&self.codex_home,
&self.cwd,
AskForApproval::Never,
SandboxModeCfg::DangerFullAccess,
) {
tracing::warn!("Failed to persist project access mode: {e:?}");
}
self.selection = Some(TrustDirectorySelection::Trust);
+17
View File
@@ -48,6 +48,23 @@ Notes:
- When `preferred_auth_method = "chatgpt"` (default), Codex prefers ChatGPT auth if present; if only an API key is present, it will use the API key. Certain account types may also require API-key mode.
- To check which auth method is being used during a session, use the `/status` command in the TUI.
## Project .env safety (OPENAI_API_KEY)
By default, Codex will no longer read `OPENAI_API_KEY` or `AZURE_OPENAI_API_KEY` from a project’s local `.env` file.
Why: many repos include an API key in `.env` for unrelated tooling, which could cause Codex to silently use the API key instead of your ChatGPT plan in that folder.
What still works:
- `~/.code/.env` (or `~/.codex/.env`) is loaded first and may contain your `OPENAI_API_KEY` for global use.
- A shell-exported `OPENAI_API_KEY` is honored.
Project `.env` provider keys are always ignored — there is no opt‑in.
UI clarity:
- When Codex is using an API key, the chat footer shows a bold “Auth: API key” badge so it’s obvious which mode you’re in.
## Connecting on a "Headless" Machine
Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds: