| 1 | //! Where a credential came from, and — when there isn't one — where we looked. |
| 2 | //! |
| 3 | //! Ported from pi-mono's `AuthResult { auth, env, source }` |
| 4 | //! (`packages/ai/src/auth/types.ts`, MIT, Copyright (c) 2025 Mario Zechner; |
| 5 | //! full notice in the parent module). pi returns a human-readable `source` |
| 6 | //! such as `"ANTHROPIC_API_KEY"`, `"OAuth"`, or `"~/.aws/credentials"` from |
| 7 | //! every resolution so a status surface can say which place won. |
| 8 | //! |
| 9 | //! CodeWhale adds the negative half, because that is where its picker was |
| 10 | //! useless: a failed resolution carries the ordered list of places that were |
| 11 | //! actually probed, so "missing key" can name them and say what would fix it. |
| 12 | //! |
| 13 | //! Nothing here holds secret material — only labels. |
| 14 | |
| 15 | use std::borrow::Cow; |
| 16 | |
| 17 | /// One place credential resolution looked, and what would put a credential |
| 18 | /// there. Labels only; never a value. |
| 19 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 20 | pub(crate) struct CredentialProbe { |
| 21 | /// Where we looked, e.g. `"env DEEPSEEK_API_KEY"` or `"secret store slot \"deepseek\""`. |
| 22 | pub(crate) place: Cow<'static, str>, |
| 23 | /// What the user would do to make this place answer, if there is a |
| 24 | /// one-line answer. `None` when the place is informational only. |
| 25 | pub(crate) fix: Option<Cow<'static, str>>, |
| 26 | } |
| 27 | |
| 28 | impl CredentialProbe { |
| 29 | pub(crate) fn with_fix( |
| 30 | place: impl Into<Cow<'static, str>>, |
| 31 | fix: impl Into<Cow<'static, str>>, |
| 32 | ) -> Self { |
| 33 | Self { |
| 34 | place: place.into(), |
| 35 | fix: Some(fix.into()), |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// The place a credential was resolved from, or the fact that no place had |
| 41 | /// one. This is the single value every readiness surface should render. |
| 42 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 43 | pub(crate) enum CredentialSource { |
| 44 | /// `auth_mode = "none"`: the route intentionally sends no credential. |
| 45 | AuthModeNone, |
| 46 | /// A keyless self-hosted or loopback route. |
| 47 | KeylessRoute { base_url: String }, |
| 48 | /// `--api-key` on the command line (or the dispatcher's source-marked |
| 49 | /// forward of it). |
| 50 | CliOverride, |
| 51 | /// The root `api_key` compatibility slot in the config file. |
| 52 | RootConfigApiKey, |
| 53 | /// `[providers.<table>] api_key`. |
| 54 | ProviderConfigApiKey { table: String }, |
| 55 | /// `[providers.<table>] api_key_env = "<var>"`, resolved from `<var>`. |
| 56 | ProviderConfigEnv { var: String }, |
| 57 | /// An ambient provider environment variable. |
| 58 | AmbientEnv { var: String }, |
| 59 | /// CodeWhale's own durable secret store. |
| 60 | SecretStore { slot: String }, |
| 61 | /// A read-only, explicitly consented credential file owned by another CLI. |
| 62 | ExternalGrant { cli: String, path: String }, |
| 63 | /// CodeWhale-owned OAuth device-login storage (xAI today). |
| 64 | OAuth { flow: String }, |
| 65 | /// The user-global `~/.codewhale/config.toml`, consulted last so a key |
| 66 | /// saved there survives loading a workspace config. |
| 67 | UserGlobalConfig, |
| 68 | /// An expiring, process-only Codewhale account auth transform. |
| 69 | AccountSession, |
| 70 | /// Nothing had a credential. `probed` is in precedence order. |
| 71 | Missing { probed: Vec<CredentialProbe> }, |
| 72 | } |
| 73 | |
| 74 | impl CredentialSource { |
| 75 | pub(crate) fn is_present(&self) -> bool { |
| 76 | !matches!(self, Self::Missing { .. }) |
| 77 | } |
| 78 | |
| 79 | /// Short human-readable label, in pi's spirit: the name of the place, not |
| 80 | /// a sentence. Safe to render anywhere — never contains a secret. |
| 81 | pub(crate) fn label(&self) -> Cow<'static, str> { |
| 82 | match self { |
| 83 | Self::AuthModeNone => Cow::Borrowed("auth_mode = \"none\""), |
| 84 | Self::KeylessRoute { base_url } => Cow::Owned(format!("keyless route {base_url}")), |
| 85 | Self::CliOverride => Cow::Borrowed("--api-key"), |
| 86 | Self::RootConfigApiKey => Cow::Borrowed("config api_key"), |
| 87 | Self::ProviderConfigApiKey { table } => Cow::Owned(format!("[{table}] api_key")), |
| 88 | Self::ProviderConfigEnv { var } => Cow::Owned(format!("api_key_env {var}")), |
| 89 | Self::AmbientEnv { var } => Cow::Owned(var.clone()), |
| 90 | Self::SecretStore { slot } => Cow::Owned(format!("secret store \"{slot}\"")), |
| 91 | Self::ExternalGrant { cli, path } => { |
| 92 | Cow::Owned(format!("{cli} credentials (read-only) {path}")) |
| 93 | } |
| 94 | Self::OAuth { flow } => Cow::Owned(format!("{flow} OAuth")), |
| 95 | Self::AccountSession => Cow::Borrowed("Codewhale account"), |
| 96 | Self::UserGlobalConfig => Cow::Borrowed("~/.codewhale/config.toml api_key"), |
| 97 | Self::Missing { .. } => Cow::Borrowed("not found"), |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | /// The ordered places that were probed, for a failed resolution. |
| 102 | pub(crate) fn probed(&self) -> &[CredentialProbe] { |
| 103 | match self { |
| 104 | Self::Missing { probed } => probed, |
| 105 | _ => &[], |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// A resolution plus its source. Deliberately does not carry the credential: |
| 111 | /// readiness surfaces need the source, and the request path already has its |
| 112 | /// own resolver that returns the secret. |
| 113 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 114 | pub(crate) struct CredentialResolution { |
| 115 | pub(crate) source: CredentialSource, |
| 116 | } |
| 117 | |
| 118 | impl CredentialResolution { |
| 119 | pub(crate) fn found(source: CredentialSource) -> Self { |
| 120 | debug_assert!(source.is_present()); |
| 121 | Self { source } |
| 122 | } |
| 123 | |
| 124 | pub(crate) fn missing(probed: Vec<CredentialProbe>) -> Self { |
| 125 | Self { |
| 126 | source: CredentialSource::Missing { probed }, |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | pub(crate) fn is_present(&self) -> bool { |
| 131 | self.source.is_present() |
| 132 | } |
| 133 | |
| 134 | /// One line naming the places checked, for a status row. Empty when the |
| 135 | /// resolution succeeded. |
| 136 | pub(crate) fn checked_places(&self) -> String { |
| 137 | self.source |
| 138 | .probed() |
| 139 | .iter() |
| 140 | .map(|probe| probe.place.as_ref()) |
| 141 | .collect::<Vec<_>>() |
| 142 | .join(", ") |
| 143 | } |
| 144 | |
| 145 | /// The first actionable fix among the probed places, if any. |
| 146 | pub(crate) fn first_fix(&self) -> Option<&str> { |
| 147 | self.source |
| 148 | .probed() |
| 149 | .iter() |
| 150 | .find_map(|probe| probe.fix.as_deref()) |
| 151 | } |
| 152 | } |
| 153 |