diff --git a/src/core/observability.rs b/src/core/observability.rs index 9f84a28e3..c1d13735d 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -249,10 +249,39 @@ pub enum ExpectedErrorKind { /// contention (handled by the store's busy-retry loop) and unrelated DB /// failures in other domains still reach Sentry. SubconsciousSchemaUnavailable, + /// The user invoked "Import Codex CLI login" (Settings → AI → Codex auth) + /// but the Codex CLI auth at `~/.codex/auth.json` is absent or unusable: + /// the file doesn't exist (the user never ran `codex login`), can't be + /// parsed, or carries no tokens / no access token. The import RPC already + /// returns an actionable error string ("Run `codex login` first, then try + /// Codex auth again.") that the frontend surfaces inline + /// (`AIPanel.tsx` → `setCodexAuthError`), and Sentry has no remediation + /// path — we can't run `codex login` for the user. The error strings also + /// embed the absolute `~/.codex/auth.json` path (home dir / username), so + /// demoting these out of the event stream is a privacy win on top of the + /// noise reduction (mirror `FilesystemUserPathInvalid` / `DiskFull`). + /// + /// Drops Sentry TAURI-RUST-83A (~430 events / 40 users on + /// `openhuman@0.57.13`). Anchored to the `codex cli auth` / + /// `.codex/auth.json` envelope produced by + /// [`crate::openhuman::inference::openai_oauth::store::import_codex_cli_auth_from_path`] + /// — a genuine keyring/persist failure in `upsert_profile` carries neither + /// anchor, so a real defect in the import code still reaches Sentry. + CodexCliAuthUnavailable, } pub fn expected_error_kind(message: &str) -> Option { let lower = message.to_ascii_lowercase(); + // Check the Codex-CLI import envelope first: it is highly specific + // (literal `codex cli auth` / `.codex/auth.json`) and carries no overlap + // with the generic matchers below, so ordering is for clarity, not + // precedence. See `ExpectedErrorKind::CodexCliAuthUnavailable`. The + // keyring/persist failure path (`upsert_profile`) stringifies to generic + // keychain / "auth profile" / SQLite text that contains neither anchor, + // so a real defect in the import still falls through to capture. + if lower.contains("codex cli auth") || lower.contains(".codex/auth.json") { + return Some(ExpectedErrorKind::CodexCliAuthUnavailable); + } if lower.contains("local ai is disabled") { return Some(ExpectedErrorKind::LocalAiDisabled); } @@ -1577,6 +1606,21 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, "[observability] {domain}.{operation} skipped expected subconscious schema DB-unavailable error" ); } + ExpectedErrorKind::CodexCliAuthUnavailable => { + // User-state condition: the Codex CLI login at `~/.codex/auth.json` + // is missing / unparseable / has no tokens. The import RPC already + // returned the actionable "Run `codex login` first" string and the + // UI surfaces it inline — Sentry has nothing to act on. Demote at + // `info!` (mirrors `LocalAiBinaryMissing`). Do NOT include the raw + // `message`: it embeds the absolute `~/.codex/auth.json` path + // (home dir / username). Log only domain/operation/kind — no PII. + tracing::info!( + domain = domain, + operation = operation, + kind = "codex_cli_auth_unavailable", + "[observability] {domain}.{operation} skipped expected codex-cli auth-unavailable error" + ); + } } } @@ -2041,6 +2085,62 @@ mod tests { ); } + /// Sentry TAURI-RUST-83A: the Codex-CLI import user-state errors must + /// classify as `CodexCliAuthUnavailable` so the ~430-event flood stays out + /// of Sentry. Verbatim envelope shapes from + /// `openai_oauth::store::import_codex_cli_auth_from_path` (with a fake path + /// in place of the real `~/.codex/auth.json`). + #[test] + fn classifies_codex_cli_import_user_state_as_expected() { + for msg in [ + "Could not read Codex CLI auth at /home/u/.codex/auth.json: No such file \ + or directory. Run `codex login` first, then try Codex auth again.", + "Could not parse Codex CLI auth at /home/u/.codex/auth.json: expected value. \ + Run `codex login` again, then try Codex auth again.", + "Codex CLI auth at /home/u/.codex/auth.json has no tokens. Run `codex login` first.", + "Codex CLI auth at /home/u/.codex/auth.json has no access token. Run `codex login` first.", + "home directory is not set; cannot find ~/.codex/auth.json", + ] { + assert_eq!( + expected_error_kind(msg), + Some(ExpectedErrorKind::CodexCliAuthUnavailable), + "must classify as CodexCliAuthUnavailable: {msg}" + ); + } + } + + /// Exercise the full demotion path (classifier -> report arm) for a codex + /// auth-unavailable message: it must take the expected branch and not panic. + #[test] + fn report_error_or_expected_demotes_codex_auth_unavailable() { + report_error_or_expected( + "Could not read Codex CLI auth at /home/u/.codex/auth.json: No such file \ + or directory. Run `codex login` first, then try Codex auth again.", + "inference", + "openai_oauth_import_codex_cli", + &[], + ); + } + + /// Guard against over-suppression on the import path: a genuine + /// keyring/persist failure (`upsert_profile`) or an unrelated error carries + /// neither the `codex cli auth` nor the `.codex/auth.json` anchor and MUST + /// still reach Sentry (stay `None`) so a real defect isn't blinded. + #[test] + fn does_not_classify_codex_persist_failure_as_codex_auth_unavailable() { + for msg in [ + "failed to write auth profile store: keyring error: access denied", + "Auth profile not found: provider:openai/oauth", + "unable to open database file", + ] { + assert_ne!( + expected_error_kind(msg), + Some(ExpectedErrorKind::CodexCliAuthUnavailable), + "real-defect/unrelated error must NOT be demoted as codex auth-unavailable: {msg}" + ); + } + } + /// Sentry TAURI-RUST-R4: the composio direct-mode factory bail must /// classify as `ApiKeyMissing` so any residual emit (explicit /// execute/authorize call with no key) stays out of Sentry. Uses the diff --git a/src/openhuman/inference/openai_oauth/flow_tests.rs b/src/openhuman/inference/openai_oauth/flow_tests.rs index 81465ad28..aaea4df19 100644 --- a/src/openhuman/inference/openai_oauth/flow_tests.rs +++ b/src/openhuman/inference/openai_oauth/flow_tests.rs @@ -466,6 +466,67 @@ fn import_codex_cli_auth_file_reports_missing_file_with_login_hint() { assert!(err.contains("codex login")); } +/// Drift-proof coupling: every user-state error the real Codex-CLI import +/// producer emits MUST classify as `CodexCliAuthUnavailable`, so the Sentry +/// demotion at `ops.rs` (TAURI-RUST-83A) keeps working even if the wording +/// changes. If a future edit to `store.rs` drops the `codex cli auth` / +/// `.codex/auth.json` anchor from a message, this test fails in CI. +#[test] +fn codex_import_user_state_errors_classify_as_expected() { + use crate::core::observability::{expected_error_kind, ExpectedErrorKind}; + + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + + // Missing file (no `codex login`). + let missing = import_codex_cli_auth_from_path(&config, &tmp.path().join("missing-auth.json")) + .unwrap_err(); + + // Unparseable file. + let garbage_path = tmp.path().join("garbage-auth.json"); + std::fs::write(&garbage_path, b"not json").unwrap(); + let garbage = import_codex_cli_auth_from_path(&config, &garbage_path).unwrap_err(); + + // Parses but carries no tokens. + let no_tokens_path = tmp.path().join("no-tokens-auth.json"); + std::fs::write(&no_tokens_path, b"{}").unwrap(); + let no_tokens = import_codex_cli_auth_from_path(&config, &no_tokens_path).unwrap_err(); + + // Parses with a tokens object but no access token. + let no_access_path = tmp.path().join("no-access-auth.json"); + std::fs::write(&no_access_path, br#"{"tokens":{"refresh_token":"r"}}"#).unwrap(); + let no_access = import_codex_cli_auth_from_path(&config, &no_access_path).unwrap_err(); + + for err in [&missing, &garbage, &no_tokens, &no_access] { + assert_eq!( + expected_error_kind(err), + Some(ExpectedErrorKind::CodexCliAuthUnavailable), + "codex import user-state error must classify as CodexCliAuthUnavailable: {err}" + ); + } +} + +/// Exercise the ops entry point (`inference_openai_oauth_import_codex_cli`) on +/// the failure path so the `report_error_or_expected` call at the match arm is +/// covered: point `CODEX_HOME` at an empty dir (no `auth.json`) and assert the +/// RPC surfaces the actionable error. +#[tokio::test] +async fn inference_import_codex_cli_surfaces_error_when_auth_missing() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let _env_guard = EnvVarGuard::set("CODEX_HOME", tmp.path()); + + let err = crate::openhuman::inference::ops::inference_openai_oauth_import_codex_cli(&config) + .await + .unwrap_err(); + + assert!(err.contains("Could not read Codex CLI auth")); + assert!(err.contains("codex login")); +} + #[test] fn openai_oauth_status_reports_token_profile_as_disconnected() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs index 14084b43b..73438bae2 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -446,7 +446,17 @@ pub async fn inference_openai_oauth_import_codex_cli( .map(|payload| RpcOutcome::single_log(payload, "openai oauth imported from codex cli")); match &result { Ok(_) => debug!("{LOG_PREFIX} openai_oauth_import_codex_cli:ok"), - Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_import_codex_cli:error"), + // Most failures here are expected user-state (no `~/.codex/auth.json`, + // user never ran `codex login`, stale/empty file) — the UI already + // surfaces the actionable error, so route through the observability + // classifier to keep that flood out of Sentry (TAURI-RUST-83A) while a + // genuine keyring/persist defect still falls through to a real event. + Err(err) => crate::core::observability::report_error_or_expected( + err, + "inference", + "openai_oauth_import_codex_cli", + &[], + ), } result }