Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions codex-rs/rmcp-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ codex-api = { workspace = true }
codex-config = { workspace = true }
codex-exec-server = { workspace = true }
codex-keyring-store = { workspace = true }
codex-otel = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the Bazel lockfile update

Because this change adds new Rust dependency edges for codex-rmcp-client and updates Cargo.lock, leaving MODULE.bazel.lock out of the diff will trip the repo's Bazel lock-drift check in CI; run just bazel-lock-update from the repo root and include the resulting lockfile update with these dependency changes.

AGENTS.md reference: AGENTS.md:L37-L39

Useful? React with 👍 / 👎.

codex-protocol = { workspace = true }
codex-secrets = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-home-dir = { workspace = true }
Expand Down
72 changes: 54 additions & 18 deletions codex-rs/rmcp-client/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

mod refresh_lock;
mod refresh_transaction;
mod resolution_state;
mod resolved_store;
mod store_lock;

Expand Down Expand Up @@ -58,6 +59,8 @@ use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tracing::warn;

use self::resolution_state::StoreResolutionReason;
use self::resolution_state::record_store_resolution;
use self::store_lock::OAuthStore;
use self::store_lock::OAuthStoreLock;
use self::store_lock::OAuthStoreLockFailure;
Expand Down Expand Up @@ -282,21 +285,53 @@ fn save_oauth_tokens_with_keyring_store<K: KeyringStore + Clone + 'static>(
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<()> {
match store_mode {
OAuthCredentialsStoreMode::Auto => save_oauth_tokens_with_keyring_with_fallback_to_file(
keyring_store,
keyring_backend_kind,
server_name,
tokens,
),
OAuthCredentialsStoreMode::File => save_oauth_tokens_to_file(tokens),
OAuthCredentialsStoreMode::Keyring => save_oauth_tokens_with_keyring_and_cleanup_file(
keyring_store,
keyring_backend_kind,
server_name,
tokens,
),
}
let (resolved_store, reason) = match store_mode {
OAuthCredentialsStoreMode::Auto => {
let resolved_store = save_oauth_tokens_with_keyring_with_fallback_to_file(
keyring_store,
keyring_backend_kind,
server_name,
tokens,
)?;
let reason = match resolved_store {
ResolvedOAuthCredentialStore::File => {
StoreResolutionReason::AutoSaveToFileAfterKeyringError
}
ResolvedOAuthCredentialStore::Keyring(_) => {
StoreResolutionReason::AutoSaveToKeyring
}
};
(resolved_store, reason)
}
OAuthCredentialsStoreMode::File => {
save_oauth_tokens_to_file(tokens)?;
(
ResolvedOAuthCredentialStore::File,
StoreResolutionReason::ConfiguredSave,
)
}
OAuthCredentialsStoreMode::Keyring => {
save_oauth_tokens_with_keyring_and_cleanup_file(
keyring_store,
keyring_backend_kind,
server_name,
tokens,
)?;
(
ResolvedOAuthCredentialStore::Keyring(keyring_backend_kind),
StoreResolutionReason::ConfiguredSave,
)
}
};
record_store_resolution(
server_name,
&tokens.url,
store_mode,
keyring_backend_kind,
resolved_store,
reason,
);
Ok(())
}

fn save_oauth_tokens_with_keyring<K: KeyringStore + Clone + 'static>(
Expand Down Expand Up @@ -399,14 +434,14 @@ fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
) -> Result<ResolvedOAuthCredentialStore> {
match save_oauth_tokens_with_keyring_and_cleanup_file(
keyring_store,
keyring_backend_kind,
server_name,
tokens,
) {
Ok(()) => Ok(()),
Ok(()) => Ok(ResolvedOAuthCredentialStore::Keyring(keyring_backend_kind)),
// As on load, a store lock failure is a coordination failure rather than evidence that
// the keyring backend is unavailable. Falling back could leave a newer File token hidden
// behind a stale Secrets entry.
Expand All @@ -415,7 +450,8 @@ fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone
let message = error.to_string();
warn!("falling back to file storage for OAuth tokens: {message}");
save_oauth_tokens_to_file(tokens)
.with_context(|| format!("failed to write OAuth tokens to keyring: {message}"))
.with_context(|| format!("failed to write OAuth tokens to keyring: {message}"))?;
Ok(ResolvedOAuthCredentialStore::File)
}
}
}
Expand Down
253 changes: 253 additions & 0 deletions codex-rs/rmcp-client/src/oauth/resolution_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! Best-effort diagnostics for changes in MCP OAuth store resolution.
//!
//! This token-free state is observational, never authoritative. OAuth still resolves from config
//! and available credentials; failure to read, lock, or write this file must not affect credential
//! selection or an OAuth operation. The last observation intentionally survives logout so a later
//! login can report that `Auto` selected a different store.

use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::time::Duration;

use anyhow::Context;
use anyhow::Result;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_utils_home_dir::find_codex_home;
use codex_utils_path::write_atomically;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;

use super::ResolvedOAuthCredentialStore;
use super::compute_store_key;
use super::store_lock::OAuthStore;
use super::store_lock::OAuthStoreLock;

const RESOLUTION_STATE_FILENAME: &str = ".mcp-oauth-store-resolutions.json";
const RESOLUTION_STATE_VERSION: u32 = 1;
const RESOLUTION_STATE_LOCK_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 250);
const RESOLUTION_CHANGED_METRIC: &str = "codex.mcp.oauth.store_resolution_changed";

#[derive(Debug, Clone, Copy)]
pub(super) enum StoreResolutionReason {
AutoLoadFromKeyring,
AutoLoadFromFileAfterMissingKeyring,
AutoLoadFromFileAfterKeyringError,
AutoSaveToKeyring,
AutoSaveToFileAfterKeyringError,
ConfiguredLoad,
ConfiguredSave,
}

impl StoreResolutionReason {
fn as_str(self) -> &'static str {
match self {
Self::AutoLoadFromKeyring => "auto_load_keyring",
Self::AutoLoadFromFileAfterMissingKeyring => "auto_load_file_keyring_missing",
Self::AutoLoadFromFileAfterKeyringError => "auto_load_file_keyring_error",
Self::AutoSaveToKeyring => "auto_save_keyring",
Self::AutoSaveToFileAfterKeyringError => "auto_save_file_keyring_error",
Self::ConfiguredLoad => "configured_load",
Self::ConfiguredSave => "configured_save",
}
}
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum ObservedStore {
File,
Keyring,
}

impl ObservedStore {
fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Keyring => "keyring",
}
}
}

impl From<ResolvedOAuthCredentialStore> for ObservedStore {
fn from(store: ResolvedOAuthCredentialStore) -> Self {
match store {
ResolvedOAuthCredentialStore::File => Self::File,
ResolvedOAuthCredentialStore::Keyring(_) => Self::Keyring,
}
}
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
struct StoreResolution {
store_mode: OAuthCredentialsStoreMode,
keyring_backend: AuthKeyringBackendKind,
resolved_store: ObservedStore,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ResolutionState {
#[serde(default = "resolution_state_version")]
version: u32,
#[serde(default)]
resolutions: BTreeMap<String, StoreResolution>,
}

impl Default for ResolutionState {
fn default() -> Self {
Self {
version: RESOLUTION_STATE_VERSION,
resolutions: BTreeMap::new(),
}
}
}

fn resolution_state_version() -> u32 {
RESOLUTION_STATE_VERSION
}

#[derive(Debug, PartialEq, Eq)]
struct StoreResolutionChange {
previous: ObservedStore,
current: ObservedStore,
}

pub(super) fn record_store_resolution(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend: AuthKeyringBackendKind,
resolved_store: ResolvedOAuthCredentialStore,
reason: StoreResolutionReason,
) {
let result = match find_codex_home() {
Ok(codex_home) => record_store_resolution_in(
&codex_home,
server_name,
url,
store_mode,
keyring_backend,
resolved_store,
),
Err(error) => Err(error.into()),
};

match result {
Ok(Some(change)) => {
warn!(
server_name,
previous_store = change.previous.as_str(),
resolved_store = change.current.as_str(),
resolution_reason = reason.as_str(),
"MCP OAuth Auto storage resolved differently than its previous use"
);
if let Some(metrics) = codex_otel::global() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can permanently drop the drift signal in the MCP CLI. We persist the new baseline before checking codex_otel::global(), but codex mcp list and mcp login / OAuth-enabled mcp add run without building an OtelProvider. If one of those commands first sees the transition, the counter is skipped and the next telemetry-enabled process sees no change

let _ = metrics.counter(
RESOLUTION_CHANGED_METRIC,
/*inc*/ 1,
&[
("previous_store", change.previous.as_str()),
("resolved_store", change.current.as_str()),
("reason", reason.as_str()),
],
);
}
}
Ok(None) => {}
Err(error) => {
warn!(
server_name,
resolution_reason = reason.as_str(),
error = %error,
"failed to update MCP OAuth store resolution diagnostics"
);
}
}
}

fn record_store_resolution_in(
codex_home: &Path,
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend: AuthKeyringBackendKind,
resolved_store: ResolvedOAuthCredentialStore,
) -> Result<Option<StoreResolutionChange>> {
// This short, best-effort lock serializes only the diagnostic map. It must not make an OAuth
// operation wait behind the 60-second credential-store lock budget.
let _lock = OAuthStoreLock::acquire_in(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the “best-effort” diagnostic part of the auth critical path

codex_home,
OAuthStore::ResolutionState,
RESOLUTION_STATE_LOCK_TIMEOUT,
)?;
let path = codex_home.join(RESOLUTION_STATE_FILENAME);
let mut state = read_resolution_state(&path)?;
anyhow::ensure!(
state.version == RESOLUTION_STATE_VERSION,
"unsupported MCP OAuth store resolution state version {}",
state.version
);

let store_key = compute_store_key(server_name, url)?;
let current = StoreResolution {
store_mode,
keyring_backend,
resolved_store: resolved_store.into(),
};
let previous = state.resolutions.get(&store_key).copied();
if previous == Some(current) {
return Ok(None);
}

state.resolutions.insert(store_key, current);
let serialized = serde_json::to_string(&state)
.context("failed to serialize MCP OAuth store resolution diagnostics")?;
write_atomically(&path, &serialized).with_context(|| {
format!(
"failed to write MCP OAuth store resolution diagnostics at {}",
path.display()
)
})?;

// Explicit modes and keyring-backend changes are intentional authority changes, so they reset
// the comparison baseline. Only repeated Auto resolution under one backend indicates the
// availability drift this diagnostic is meant to surface.
Ok(previous.and_then(|previous| {
(previous.store_mode == OAuthCredentialsStoreMode::Auto

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also fires with a healthy keyring: an existing File credential loads through Ok(None), then a normal re-login saves to Keyring and removes File. That expected migration gets reported as availability drift, and without the previous reason the metric can’t distinguish it from a real backend failure

&& current.store_mode == OAuthCredentialsStoreMode::Auto
&& previous.keyring_backend == current.keyring_backend
&& previous.resolved_store != current.resolved_store)
.then_some(StoreResolutionChange {
previous: previous.resolved_store,
current: current.resolved_store,
})
}))
}

fn read_resolution_state(path: &Path) -> Result<ResolutionState> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(ResolutionState::default()),
Err(error) => {
return Err(error).with_context(|| {
format!(
"failed to read MCP OAuth store resolution diagnostics at {}",
path.display()
)
});
}
};
serde_json::from_str(&contents).with_context(|| {
format!(
"failed to parse MCP OAuth store resolution diagnostics at {}",
path.display()
)
})
}

#[cfg(test)]
#[path = "resolution_state_tests.rs"]
mod tests;
Loading
Loading