Compare commits

...

3 Commits

Author SHA1 Message Date
viyatb-oai
d72e4b392f fix(core): avoid stale keyring shadowing fallback auth 2026-02-20 22:03:53 -08:00
viyatb-oai
bbbe6400a5 fix(core): preserve keyring delete errors for auto auth 2026-02-20 21:47:07 -08:00
mikhail-oai
5802e4868f Default CLI auth credentials store to auto 2026-02-16 22:10:59 -05:00
6 changed files with 154 additions and 29 deletions

View File

@@ -339,6 +339,7 @@ unwrap_used = "deny"
# silence the false positive here instead of deleting a real dependency.
[workspace.metadata.cargo-shear]
ignored = [
"askama",
"icu_provider",
"openssl-sys",
"codex-utils-readiness",

View File

@@ -804,7 +804,13 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
#[tokio::test]
async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -844,7 +850,28 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
};
pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey));
assert!(codex_home.path().join("auth.json").exists());
drop(mcp);
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let get_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let get_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
)
.await??;
let account: GetAccountResponse = to_response(get_resp)?;
let expected = GetAccountResponse {
account: Some(Account::ApiKey {}),
requires_openai_auth: true,
};
assert_eq!(account, expected);
Ok(())
}

View File

@@ -169,4 +169,4 @@ wiremock = { workspace = true }
zstd = { workspace = true }
[package.metadata.cargo-shear]
ignored = ["openssl-sys"]
ignored = ["askama", "openssl-sys"]

View File

@@ -125,6 +125,13 @@
"AuthCredentialsStoreMode": {
"description": "Determine where Codex should store CLI auth credentials.",
"oneOf": [
{
"description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.",
"enum": [
"auto"
],
"type": "string"
},
{
"description": "Persist credentials in CODEX_HOME/auth.json.",
"enum": [
@@ -139,13 +146,6 @@
],
"type": "string"
},
{
"description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.",
"enum": [
"auto"
],
"type": "string"
},
{
"description": "Store credentials in memory only for the current process.",
"enum": [
@@ -1286,7 +1286,7 @@
}
],
"default": null,
"description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file."
"description": "Preferred backend for storing CLI auth credentials. auto (default): Use the keyring if available, otherwise use a file. file: Use a file in the Codex home directory. keyring: Use an OS-specific keyring service."
},
"compact_prompt": {
"description": "Compact prompt used for history compaction.",

View File

@@ -29,13 +29,13 @@ use once_cell::sync::Lazy;
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthCredentialsStoreMode {
/// Use keyring when available; otherwise, fall back to a file in CODEX_HOME.
#[default]
Auto,
/// Persist credentials in CODEX_HOME/auth.json.
File,
/// Persist credentials in the keyring. Fail if unavailable.
Keyring,
/// Use keyring when available; otherwise, fall back to a file in CODEX_HOME.
Auto,
/// Store credentials in memory only for the current process.
Ephemeral,
}
@@ -190,6 +190,15 @@ impl KeyringAuthStorage {
}
}
}
fn delete_from_keyring_only(&self) -> std::io::Result<bool> {
let key = compute_store_key(&self.codex_home)?;
self.keyring_store
.delete(KEYRING_SERVICE, &key)
.map_err(|err| {
std::io::Error::other(format!("failed to delete auth from keyring: {err}"))
})
}
}
impl AuthStorageBackend for KeyringAuthStorage {
@@ -210,13 +219,7 @@ impl AuthStorageBackend for KeyringAuthStorage {
}
fn delete(&self) -> std::io::Result<bool> {
let key = compute_store_key(&self.codex_home)?;
let keyring_removed = self
.keyring_store
.delete(KEYRING_SERVICE, &key)
.map_err(|err| {
std::io::Error::other(format!("failed to delete auth from keyring: {err}"))
})?;
let keyring_removed = self.delete_from_keyring_only()?;
let file_removed = delete_file_if_exists(&self.codex_home)?;
Ok(keyring_removed || file_removed)
}
@@ -254,14 +257,37 @@ impl AuthStorageBackend for AutoAuthStorage {
Ok(()) => Ok(()),
Err(err) => {
warn!("failed to save auth to keyring, falling back to file storage: {err}");
self.file_storage.save(auth)
self.file_storage.save(auth)?;
if let Err(delete_err) = self.keyring_storage.delete_from_keyring_only() {
warn!(
"failed to clear stale keyring auth after fallback file save: {delete_err}"
);
}
Ok(())
}
}
}
fn delete(&self) -> std::io::Result<bool> {
// Keyring storage will delete from disk as well
self.keyring_storage.delete()
match self.keyring_storage.delete() {
Ok(removed) => Ok(removed),
Err(err) => {
warn!("failed to delete auth from keyring, falling back to file deletion: {err}");
match self.file_storage.delete() {
Ok(file_removed) => {
if file_removed {
warn!(
"deleted fallback auth.json after keyring delete failure; returning keyring error"
);
}
Err(err)
}
Err(file_err) => Err(std::io::Error::other(format!(
"{err}; additionally failed to delete fallback auth.json: {file_err}"
))),
}
}
}
}
}
@@ -728,6 +754,44 @@ mod tests {
Ok(())
}
#[test]
fn auto_auth_storage_save_fallback_clears_stale_keyring_to_prevent_stale_load()
-> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let key = compute_store_key(codex_home.path())?;
let stale_keyring_auth = auth_with_prefix("stale-keyring");
seed_keyring_with_auth(
&mock_keyring,
|| compute_store_key(codex_home.path()),
&stale_keyring_auth,
)?;
// Make the next keyring save fail so AutoAuthStorage falls back to auth.json.
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
let expected = auth_with_prefix("fallback-file");
storage.save(&expected)?;
assert!(
!mock_keyring.contains(&key),
"stale keyring entry should be cleared after fallback file save"
);
let loaded = storage.load()?;
assert_eq!(
loaded,
Some(expected),
"load should not resurrect stale keyring credentials after fallback save"
);
Ok(())
}
#[test]
fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> {
let codex_home = tempdir()?;
@@ -755,4 +819,37 @@ mod tests {
);
Ok(())
}
#[test]
fn auto_auth_storage_delete_reports_error_when_keyring_delete_fails() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let mock_keyring = MockKeyringStore::default();
let storage = AutoAuthStorage::new(
codex_home.path().to_path_buf(),
Arc::new(mock_keyring.clone()),
);
let (key, auth_file) = seed_keyring_and_fallback_auth_file_for_delete(
&mock_keyring,
codex_home.path(),
|| compute_store_key(codex_home.path()),
)?;
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "delete".into()));
let err = storage
.delete()
.expect_err("keyring delete failure should propagate");
assert!(
err.to_string()
.contains("failed to delete auth from keyring"),
"unexpected error: {err}"
);
assert!(
!auth_file.exists(),
"fallback auth.json should still be removed when keyring delete fails"
);
assert!(
mock_keyring.contains(&key),
"keyring entry should remain when keyring delete fails"
);
Ok(())
}
}

View File

@@ -261,9 +261,9 @@ pub struct Config {
pub cwd: PathBuf,
/// Preferred store for CLI auth credentials.
/// file (default): Use a file in the Codex home directory.
/// auto (default): Use the OS-specific keyring service if available, otherwise use a file.
/// file: Use a file in the Codex home directory.
/// keyring: Use an OS-specific keyring service.
/// auto: Use the OS-specific keyring service if available, otherwise use a file.
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
/// Definition for MCP servers that Codex can reach out to for tool calls.
@@ -923,9 +923,9 @@ pub struct ConfigToml {
pub forced_login_method: Option<ForcedLoginMethod>,
/// Preferred backend for storing CLI auth credentials.
/// file (default): Use a file in the Codex home directory.
/// auto (default): Use the keyring if available, otherwise use a file.
/// file: Use a file in the Codex home directory.
/// keyring: Use an OS-specific keyring service.
/// auto: Use the keyring if available, otherwise use a file.
#[serde(default)]
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
@@ -2465,7 +2465,7 @@ trust_level = "trusted"
}
#[test]
fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
fn config_defaults_to_auto_cli_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
@@ -2477,7 +2477,7 @@ trust_level = "trusted"
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::File,
AuthCredentialsStoreMode::Auto,
);
Ok(())