From 92cf012569f60a1656be4029b0cda8b427e728d3 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 25 Aug 2026 20:59:06 -0400 Subject: [PATCH 1/2] Read the OpenCode Go key from the stores the local CLIs already use. OpenCode Go resolved its key from the config file and the two environment variables only, so a machine whose key lives in the OS credential store reported the provider as unconfigured even though a CLI on the same box held a working key. cairn-code writes that key under service `cairn-code`, account `opencode-go`, and the upstream `opencode` CLI keeps its own copy in `auth.json` beside its data directory; both are now read, after the config and environment so an explicit key still wins. Refs #40 --- README.md | 2 +- src/credentials/mod.rs | 97 +++++++++++++++++++++++++++++++++++++++++- src/fetch.rs | 6 ++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d8fe682..0504462 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 | `OPENCODE_GO_API_KEY` / `OPENCODE_API_KEY`, the OS keyring entry cairn-code writes, `~/.local/share/opencode/auth.json`, or an API key in config | | 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..d16290d 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,63 @@ 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() +} + +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. +fn key_from_secret(data: &[u8]) -> Option { + if let Ok(value) = serde_json::from_slice::(data) { + return opencode_key_from_json(&value); + } + let key = String::from_utf8_lossy(data).trim().to_string(); + (!key.is_empty()).then_some(key) +} + +/// `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) +} + +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 +986,42 @@ 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_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); + } + #[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. From d756f9e2363a105ac515a28d279a7b81154c3ae8 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 25 Aug 2026 21:13:19 -0400 Subject: [PATCH 2/2] Reject a corrupt keyring payload instead of passing it off as a key. A payload that opens like JSON but does not parse, or that is not valid UTF-8, was returned as if it were a bare key. Being first in the search order, it displaced the entries checked after it, so one truncated blob turned a machine with a working key into a 401. Both shapes are now rejected and the search continues. Also documents the OpenCode Go credential sources in the order they are actually consulted, config key first, and names the XDG path. Refs #40 --- README.md | 2 +- src/credentials/mod.rs | 32 ++++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0504462..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`, the OS keyring entry cairn-code writes, `~/.local/share/opencode/auth.json`, 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 d16290d..45a6cfa 100644 --- a/src/credentials/mod.rs +++ b/src/credentials/mod.rs @@ -411,6 +411,8 @@ 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() @@ -421,12 +423,17 @@ fn opencode_key_candidates(read: impl Fn(&str, Option<&str>) -> Option>) /// 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 { - if let Ok(value) = serde_json::from_slice::(data) { - return opencode_key_from_json(&value); + 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()?); } - let key = String::from_utf8_lossy(data).trim().to_string(); - (!key.is_empty()).then_some(key) + (!text.is_empty()).then(|| text.to_string()) } /// `auth.json` in the `opencode` CLI's own data directory, which keys each @@ -443,6 +450,8 @@ fn opencode_auth_file_key() -> Option { .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")) @@ -1008,6 +1017,19 @@ mod tests { 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!( @@ -1020,6 +1042,8 @@ mod tests { ); 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]