feat: use encrypted local secrets for CLI auth (#27539)

## Why

Windows Credential Manager limits generic credential blobs to 2,560
bytes. Large serialized ChatGPT auth payloads can exceed that limit, so
keyring-mode CLI auth needs a backend that keeps only the encryption key
in the OS keyring and stores the payload in Codex's encrypted
local-secrets file.

This is the third PR in the encrypted-auth stack:

1. #27504 — feature and config selection
2. #27535 — auth-specific local-secrets namespaces
3. This PR — CLI auth implementation and activation
4. MCP OAuth implementation and activation

## What Changed

- Added encrypted CLI-auth storage using the `CliAuth` secrets
namespace.
- Preserved direct keyring storage for platforms/configurations where it
remains selected.
- Selected the backend consistently for login, logout, refresh,
device-code login, auth loading, and login restrictions.
- Threaded resolved bootstrap/full config through CLI, exec, TUI,
app-server account handling, cloud config, and cloud tasks.
- Removed stale `auth.json` fallback data after successful encrypted
saves and removed encrypted, direct-keyring, and fallback data during
logout.
- Added storage and integration coverage for both direct and encrypted
keyring modes.

MCP OAuth persistence is intentionally left to the next PR.

## Validation

- `just test -p codex-login` — 131 passed
- `just test -p codex-cli` — 280 passed
- `just test -p codex-app-server v2::account` — 25 passed
- `just test -p codex-cloud-config service` — 21 passed, 7 skipped
- `just fix -p codex-login`
- `just fix -p codex-cli`
- `just fmt`
This commit is contained in:
Celia Chen
2026-06-12 14:23:50 -07:00
committed by GitHub
Unverified
parent 576f603440
commit 56c97e3b5c
37 changed files with 857 additions and 164 deletions
+14 -9
View File
@@ -62,8 +62,10 @@ use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigTomlLoadResult;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_and_load_options;
use codex_core::config::load_config_toml_with_layer_stack;
use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind;
use codex_core::config::resolve_oss_provider;
use codex_core::config::resolve_profile_v2_config_path;
use codex_core::find_thread_meta_by_name_str;
@@ -331,7 +333,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
..Default::default()
};
let bootstrap_config_toml = load_config_toml_or_exit(
let bootstrap_config = load_bootstrap_config_or_exit(
&codex_home,
Some(&config_cwd),
cli_kv_overrides.clone(),
@@ -340,6 +342,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
CloudConfigBundleLoader::default(),
)
.await;
let bootstrap_config_toml = &bootstrap_config.config_toml;
let chatgpt_base_url = bootstrap_config_toml
.chatgpt_base_url
@@ -351,6 +354,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
bootstrap_config_toml
.cli_auth_credentials_store
.unwrap_or_default(),
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
chatgpt_base_url,
)
.await;
@@ -359,12 +363,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
let run_cloud_config_bundle = cloud_config_bundle.clone();
let model_provider = if oss {
let config_toml_with_cloud_config;
let bootstrap_config_with_cloud_config;
let config_toml_for_oss = if oss_provider.is_none() {
// The first load intentionally skips cloud config so we can read
// auth/base-url settings needed to fetch the bundle. If OSS mode
// needs a default provider from config, reload with the bundle.
config_toml_with_cloud_config = load_config_toml_or_exit(
bootstrap_config_with_cloud_config = load_bootstrap_config_or_exit(
&codex_home,
Some(&config_cwd),
cli_kv_overrides.clone(),
@@ -373,9 +377,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
cloud_config_bundle.clone(),
)
.await;
&config_toml_with_cloud_config
&bootstrap_config_with_cloud_config.config_toml
} else {
&bootstrap_config_toml
bootstrap_config_toml
};
let resolved = resolve_oss_provider(oss_provider.as_deref(), config_toml_for_oss);
@@ -466,6 +470,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
if let Err(err) = enforce_login_restrictions(&AuthConfig {
codex_home: config.codex_home.to_path_buf(),
auth_credentials_store_mode: config.cli_auth_credentials_store_mode,
keyring_backend_kind: config.auth_keyring_backend_kind(),
forced_login_method: config.forced_login_method,
forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url: Some(config.chatgpt_base_url.clone()),
@@ -609,15 +614,15 @@ where
}
#[allow(clippy::print_stderr)]
async fn load_config_toml_or_exit(
async fn load_bootstrap_config_or_exit(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_kv_overrides: Vec<(String, codex_config::TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_config_bundle: CloudConfigBundleLoader,
) -> codex_config::config_toml::ConfigToml {
match load_config_as_toml_with_cli_and_load_options(
) -> ConfigTomlLoadResult {
match load_config_toml_with_layer_stack(
codex_home,
cwd,
cli_kv_overrides,