Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ Codex profile token stats for local Accounts displays. Bounded seven-day account
usage estimates are kept in
`~/.codex/decodex/account-usage-history.jsonl`; the file stores daily percentage
snapshots plus non-secret capacity weights for local display and no token material.
Refresh authentication failures mark the account `auth_failed` in `accounts.jsonl`;
Decodex will not select or manually activate that account again until it is re-logged
or replaced.
To switch the account used by the Codex CLI itself, run
`decodex account use <selector>` or use the Decodex App row action; this overwrites
`$CODEX_HOME/auth.json` or `~/.codex/auth.json` from the matching `accounts.jsonl`
Expand Down
76 changes: 73 additions & 3 deletions apps/decodex/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ impl AccountStore {
if record.disabled {
eyre::bail!("Decodex account `{selector}` is disabled and cannot be used by Codex.");
}
if record.auth_failure().is_some() {
eyre::bail!(
"Decodex account `{selector}` is auth_failed and must be re-logged before Codex can use it."
);
}

record.validate_importable()?;

Expand Down Expand Up @@ -1037,6 +1042,10 @@ struct AccountPoolRecord {
#[serde(skip_serializing_if = "Option::is_none")]
last_selected_at_unix_epoch: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_failed_at_unix_epoch: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_failure: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_mode: Option<String>,
#[serde(rename = "OPENAI_API_KEY", skip_serializing_if = "Option::is_none")]
openai_api_key: Option<String>,
Expand All @@ -1059,6 +1068,8 @@ impl AccountPoolRecord {
cooldown_until_unix_epoch: None,
cooldown_until: None,
last_selected_at_unix_epoch: None,
auth_failed_at_unix_epoch: None,
auth_failure: None,
auth_mode: auth.auth_mode,
openai_api_key: auth.openai_api_key,
tokens: auth.tokens,
Expand Down Expand Up @@ -1102,6 +1113,14 @@ impl AccountPoolRecord {
|| self.account_id().map(redact_account_id).as_deref() == Some(selector)
}

fn auth_failure(&self) -> Option<&str> {
self.auth_failure
.as_deref()
.map(str::trim)
.filter(|failure| !failure.is_empty())
.or_else(|| self.auth_failed_at_unix_epoch.map(|_| "authentication failed"))
}

fn matches_account_identity(&self, identity: &AccountIdentity) -> bool {
identity
.account_id
Expand Down Expand Up @@ -1170,6 +1189,8 @@ impl AccountPoolRecord {
.is_some();
let status = if self.disabled {
"disabled"
} else if self.auth_failure().is_some() {
"auth_failed"
} else if self.cooldown_until_unix_epoch.is_some_and(|cooldown_until| cooldown_until > now)
{
"cooldown"
Expand All @@ -1186,8 +1207,8 @@ impl AccountPoolRecord {
let recovery_action = account_recovery_action(
status,
refresh_token_present,
None,
Some("local account pool"),
if self.auth_failure().is_some() { Some("auth_failed") } else { None },
self.auth_failure().or(Some("local account pool")),
);

AccountSummary {
Expand All @@ -1206,7 +1227,10 @@ impl AccountPoolRecord {
access_token_expires_at_unix_epoch,
last_selected_at_unix_epoch: self.last_selected_at_unix_epoch,
cooldown_until_unix_epoch: self.cooldown_until_unix_epoch,
note: Some(String::from("local account pool")),
note: Some(
self.auth_failure()
.map_or_else(|| String::from("local account pool"), ToOwned::to_owned),
),
plan_type: None,
capacity_multiplier: DEFAULT_ACCOUNT_CAPACITY_MULTIPLIER,
recovery_action,
Expand Down Expand Up @@ -1275,6 +1299,10 @@ enum AccountPoolLine {
cooldown_until: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_selected_at_unix_epoch: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_failed_at_unix_epoch: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_failure: Option<String>,
auth: AuthDotJson,
},
Flat(AccountPoolRecord),
Expand All @@ -1289,6 +1317,8 @@ impl AccountPoolLine {
cooldown_until_unix_epoch,
cooldown_until,
last_selected_at_unix_epoch,
auth_failed_at_unix_epoch,
auth_failure,
auth,
} => {
let mut record = AccountPoolRecord::from_auth(auth)?;
Expand All @@ -1298,6 +1328,8 @@ impl AccountPoolLine {
record.cooldown_until_unix_epoch = cooldown_until_unix_epoch;
record.cooldown_until = cooldown_until;
record.last_selected_at_unix_epoch = last_selected_at_unix_epoch;
record.auth_failed_at_unix_epoch = auth_failed_at_unix_epoch;
record.auth_failure = auth_failure;

Ok(record)
},
Expand Down Expand Up @@ -1835,6 +1867,9 @@ fn account_recovery_action(
if status == "disabled" || status == "cooldown" {
return None;
}
if status == "auth_failed" || refresh_status == "auth_failed" {
return Some(String::from("login"));
}
if !refresh_token_present {
return Some(String::from("login"));
}
Expand Down Expand Up @@ -2061,6 +2096,8 @@ mod tests {
cooldown_until_unix_epoch: None,
cooldown_until: None,
last_selected_at_unix_epoch: None,
auth_failed_at_unix_epoch: None,
auth_failure: None,
auth_mode: None,
openai_api_key: None,
tokens: Some(CodexTokenData {
Expand Down Expand Up @@ -2113,6 +2150,37 @@ mod tests {
assert_eq!(tokens.account_id.as_deref(), Some("acct_123456"));
}

#[test]
fn use_for_codex_rejects_auth_failed_account() {
let temp_dir = TempDir::new().expect("temp dir should create");
let codex_auth_path = temp_dir.path().join(".codex/auth.json");
let store = AccountStore::new_with_codex_auth_path(
temp_dir.path().join("accounts.jsonl"),
temp_dir.path().join("config.toml"),
codex_auth_path,
);
let mut record = account_record(
"copy@example.com",
"acct_123456",
"header.eyJleHAiOjQxMDI0NDQ4MDB9.sig",
"refresh-secret",
);

record.auth_failed_at_unix_epoch = Some(1_800_000_000);
record.auth_failure = Some(String::from(
"Codex account `copy@example.com` token refresh failed with HTTP 401 Unauthorized.",
));

store.save_records(&[record]).expect("records should save");

let error = match store.use_for_codex("copy@example.com", None) {
Ok(_) => panic!("auth failed account should reject"),
Err(error) => error,
};

assert!(error.to_string().contains("auth_failed"));
}

#[test]
fn list_marks_codex_active_account() {
let temp_dir = TempDir::new().expect("temp dir should create");
Expand Down Expand Up @@ -2513,6 +2581,8 @@ mod tests {
cooldown_until_unix_epoch: None,
cooldown_until: None,
last_selected_at_unix_epoch: None,
auth_failed_at_unix_epoch: None,
auth_failure: None,
auth_mode: None,
openai_api_key: None,
tokens: Some(CodexTokenData {
Expand Down
2 changes: 1 addition & 1 deletion apps/decodex/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub(crate) use self::{
TurnContinuationGuard, execute_app_server_run, probe_app_server,
protocol_activity_idle_timeout,
},
codex_accounts::{CodexAccountPool, CodexAccountProvider},
codex_accounts::{CodexAccountAuthFailure, CodexAccountPool, CodexAccountProvider},
decodex_tool_bridge::{DecodexRunContext, DecodexToolBridge},
json_rpc::{AppServerHomePreflightFailure, AppServerProcessEnv, AppServerTransportFailure},
tracker_tool_bridge::{
Expand Down
49 changes: 46 additions & 3 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use self::protocol::{
use crate::{
agent::{
app_server::protocol::LoginAccountResponse,
codex_accounts::{CodexAccountLogin, CodexAccountProvider},
codex_accounts::{CodexAccountAuthFailure, CodexAccountLogin, CodexAccountProvider},
json_rpc,
json_rpc::{
AppServerHomePreflightFailure, AppServerOutputTimeout, AppServerProcessEnv,
Expand Down Expand Up @@ -3386,7 +3386,14 @@ fn login_codex_account_for_run(
let Some(account_provider) = request.codex_account_provider else {
return Ok(());
};
let account = account_provider.select_account()?;
let account = match account_provider.select_account() {
Ok(account) => account,
Err(error) => {
record_codex_account_failure(recorder, "account/login/select/failed", &error);

return Err(error);
},
};

recorder.set_codex_account(account.summary(), account.account_summaries())?;

Expand Down Expand Up @@ -3431,6 +3438,31 @@ fn login_codex_account_for_run(
Ok(())
}

fn record_codex_account_failure(recorder: &mut RunRecorder<'_>, event_type: &str, error: &Report) {
let auth_failure = error.downcast_ref::<CodexAccountAuthFailure>();
let error_class =
auth_failure.map(CodexAccountAuthFailure::error_class).unwrap_or("codex_account_failure");
let account_fingerprint = auth_failure.and_then(CodexAccountAuthFailure::account_fingerprint);
let email = auth_failure.and_then(CodexAccountAuthFailure::email);
let reason =
auth_failure.map_or_else(|| error.to_string(), |failure| failure.reason().to_owned());
let payload = serde_json::json!({
"errorClass": error_class,
"accountFingerprint": account_fingerprint,
"email": email,
"reason": reason,
});

if let Err(record_error) = recorder.record(event_type, &payload.to_string()) {
tracing::warn!(
?record_error,
event_type,
error_class,
"Failed to record Codex account failure event."
);
}
}

fn start_or_resume_thread_session(
client: &mut AppServerClient,
recorder: &mut RunRecorder<'_>,
Expand Down Expand Up @@ -5124,7 +5156,18 @@ fn dispatch_codex_account_refresh(
)
})?;
let params = serde_json::from_value::<ChatgptAuthTokensRefreshParams>(request.params.clone())?;
let account = account_provider.refresh_account(params.previous_account_id.as_deref())?;
let account = match account_provider.refresh_account(params.previous_account_id.as_deref()) {
Ok(account) => account,
Err(error) => {
record_codex_account_failure(
recorder,
"account/chatgptAuthTokens/refresh/failed",
&error,
);

return Err(error);
},
};
let response = ChatgptAuthTokensRefreshResponse {
access_token: account.access_token().to_owned(),
chatgpt_account_id: account.account_id().to_owned(),
Expand Down
Loading