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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 119 additions & 2 deletions src/credentials/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -392,6 +392,72 @@ fn parse_grok_entry(entry: &Value) -> Option<Token> {
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<String> {
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<u8>>) -> Vec<String> {
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<String> {
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<String> {
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> {
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.
Expand Down Expand Up @@ -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());
Expand Down
6 changes: 4 additions & 2 deletions src/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String> {
if config.has_api_key() {
return Some(config.api_key.trim().to_string());
Expand All @@ -712,6 +713,7 @@ fn opencode_key(config: &ProviderConfig) -> Option<String> {
.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.
Expand Down
Loading