diff --git a/README.md b/README.md index d8fe682..3edccb3 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ limits/ | Grok | `~/.grok/auth.json` | | Gemini | `~/.gemini/oauth_creds.json` | | GitHub Copilot | `gh auth token`, or an API key | -| OpenCode Go | `OPENCODE_GO_API_KEY` / `OPENCODE_API_KEY`, or an API key in config | +| OpenCode Go | An API key in config, then `OPENCODE_GO_API_KEY` / `OPENCODE_API_KEY`, the OS keyring entry cairn-code writes, and `$XDG_DATA_HOME/opencode/auth.json` (`~/.local/share/opencode/auth.json`) | | OpenAI, DeepSeek, OpenRouter | API key in config | For OAuth-backed providers (Claude, Antigravity, Grok, Gemini, Copilot), `limits` will run the provider's own CLI headlessly to force a token refresh if the stored one has expired, before giving up. diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs index a52146e..45a6cfa 100644 --- a/src/credentials/mod.rs +++ b/src/credentials/mod.rs @@ -2,8 +2,8 @@ //! them refreshed when they have gone stale. //! //! `limits` never asks the user to paste a token that a CLI on the same machine -//! already holds. It reads `codex login`, `claude`, `gemini`, `grok`, and -//! Antigravity's own stores directly. Nothing here writes a credential; the +//! already holds. It reads `codex login`, `claude`, `gemini`, `grok`, +//! `opencode`, and Antigravity's own stores directly. Nothing here writes a credential; the //! only mutation is asking a provider's own CLI to refresh its own token. pub mod headless; @@ -392,6 +392,72 @@ fn parse_grok_entry(entry: &Value) -> Option { None } +/// Every keyring entry that may hold an OpenCode Go API key, canonical first. +/// +/// cairn-code writes the key under the provider id it uses internally +/// (`opencode-go`); the upstream `opencode` CLI keeps its own copy in +/// `auth.json`, which [`opencode_auth_file_key`] reads. +const OPENCODE_KEYRING_ENTRIES: [(&str, &str); 3] = [ + ("cairn-code", "opencode-go"), + ("cairn-code", "opencode"), + ("opencode", "opencode-go"), +]; + +/// The OpenCode Go API key a CLI on this machine already holds. +/// +/// Unlike the OAuth providers there is nothing to refresh here: the key is a +/// long-lived secret, so the first store that has one wins. +pub fn load_opencode_key() -> Option { + opencode_key_candidates(keyring::read).into_iter().next() +} + +/// Every OpenCode Go key this machine holds, canonical store first. The +/// keyring reader is injected so the ordering can be tested without one. +fn opencode_key_candidates(read: impl Fn(&str, Option<&str>) -> Option>) -> Vec { + OPENCODE_KEYRING_ENTRIES + .iter() + .filter_map(|(service, account)| key_from_secret(&read(service, Some(account))?)) + .chain(opencode_auth_file_key()) + .collect() +} + +/// A keyring payload, which is either the bare key or the JSON blob a store +/// that keeps more than the key writes. +/// +/// A payload that opens like JSON but does not parse is a truncated or corrupt +/// blob, and so is one that is not valid UTF-8. Neither is returned as a bare +/// key: a value that can only be rejected with a 401 would otherwise take the +/// place of the stores checked after it. +fn key_from_secret(data: &[u8]) -> Option { + let text = std::str::from_utf8(data).ok()?.trim(); + if text.starts_with('{') || text.starts_with('[') { + return opencode_key_from_json(&serde_json::from_str(text).ok()?); + } + (!text.is_empty()).then(|| text.to_string()) +} + +/// `auth.json` in the `opencode` CLI's own data directory, which keys each +/// provider's credential by provider id. +fn opencode_auth_file_key() -> Option { + let base = match std::env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) { + Some(value) => PathBuf::from(value), + None => home_dir().join(".local").join("share"), + }; + let root = read_json(&base.join("opencode").join("auth.json"))?; + ["opencode", "opencode-go"] + .iter() + .filter_map(|id| root.get(*id)) + .find_map(opencode_key_from_json) +} + +/// The key out of a stored credential object, whichever of the field names +/// the writing CLI used. +fn opencode_key_from_json(value: &Value) -> Option { + string_at(value, "key") + .or_else(|| string_at(value, "api_key")) + .or_else(|| string_at(value, "access")) +} + /// Antigravity's OAuth token, from the keyring first and the on-disk stores /// after. Recent versions keep the live token in the keyring and leave the file /// behind stale, so file-first would report a permanently expired session. @@ -929,6 +995,57 @@ mod tests { ); } + #[test] + fn opencode_key_comes_from_the_cairn_code_keyring_entry() { + let candidates = opencode_key_candidates(|service, account| { + (service == "cairn-code" && account == Some("opencode-go")) + .then(|| b" sk-live ".to_vec()) + }); + + assert_eq!(candidates.first().map(String::as_str), Some("sk-live")); + } + + #[test] + fn opencode_keyring_entries_are_read_in_order() { + let candidates = opencode_key_candidates(|service, account| match (service, account) { + ("cairn-code", Some("opencode-go")) => Some(b"sk-first".to_vec()), + ("cairn-code", Some("opencode")) => Some(b"sk-second".to_vec()), + _ => None, + }); + + assert_eq!(candidates[0], "sk-first"); + assert_eq!(candidates[1], "sk-second"); + } + + #[test] + fn a_corrupt_keyring_entry_does_not_shadow_a_later_valid_one() { + let candidates = opencode_key_candidates(|service, account| match (service, account) { + // A truncated blob and a non-UTF-8 one: both would 401 if returned. + ("cairn-code", Some("opencode-go")) => Some(br#"{"key":"sk-trunc"#.to_vec()), + ("cairn-code", Some("opencode")) => Some(vec![0xff, 0xfe, 0x00]), + ("opencode", Some("opencode-go")) => Some(b"sk-valid".to_vec()), + _ => None, + }); + + assert_eq!(candidates.first().map(String::as_str), Some("sk-valid")); + } + + #[test] + fn a_json_keyring_payload_yields_the_key_rather_than_the_blob() { + assert_eq!( + key_from_secret(br#"{"type":"api","key":"sk-json"}"#), + Some("sk-json".to_string()) + ); + assert_eq!( + key_from_secret(br#"{"type":"oauth","access":"sk-access"}"#), + Some("sk-access".to_string()) + ); + assert_eq!(key_from_secret(b" "), None); + assert_eq!(key_from_secret(br#"{"type":"oauth"}"#), None); + assert_eq!(key_from_secret(br#"{"key":"sk-trunc"#), None); + assert_eq!(key_from_secret(&[0xff, 0xfe, 0x00]), None); + } + #[test] fn providers_without_a_local_store_need_no_probe() { assert!(probes(Provider::OpenCode).is_empty()); diff --git a/src/fetch.rs b/src/fetch.rs index 2e9bff7..8c1f1b1 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -250,7 +250,7 @@ impl<'a> Fetcher<'a> { if response.is_auth_failure() { return ProviderUsage::degraded( Provider::OpenCode, - "OpenCode Go rejected the key; check OPENCODE_GO_API_KEY", + "OpenCode Go rejected the key; re-authenticate the CLI or set OPENCODE_GO_API_KEY", ); } if !response.is_success() { @@ -699,7 +699,8 @@ fn copilot_unavailable(reason: &str) -> ProviderUsage { /// The OpenCode Go key: explicit config first, then the two environment /// variables in use (`OPENCODE_GO_API_KEY` is the documented one; -/// `OPENCODE_API_KEY` is what the upstream CLI sets). +/// `OPENCODE_API_KEY` is what the upstream CLI sets), then the stores the +/// local CLIs already keep it in. fn opencode_key(config: &ProviderConfig) -> Option { if config.has_api_key() { return Some(config.api_key.trim().to_string()); @@ -712,6 +713,7 @@ fn opencode_key(config: &ProviderConfig) -> Option { .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }) + .or_else(credentials::load_opencode_key) } /// Pull the session key out of either a bare key or a full Cookie header.