M3: All providers + auth #4
Generated
+2
@@ -723,12 +723,14 @@ dependencies = [
|
|||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"dirs",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
"harness-core",
|
"harness-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Credential storage for providers that authenticate outside the config file.
|
||||||
|
//!
|
||||||
|
//! A single JSON file (`~/.local/share/ai-harness/auth.json`, mode `0600`) keyed by provider
|
||||||
|
//! id. OAuth providers (Copilot) store the token triple and refresh it in place; API-key
|
||||||
|
//! providers store the bare key. Config-supplied keys take precedence over this file — this
|
||||||
|
//! is only for interactively-obtained credentials.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum AuthRecord {
|
||||||
|
/// OAuth credentials. `expires` is a unix-ms timestamp; `0` means the token never expires
|
||||||
|
/// (opencode's direct-Bearer Copilot mode).
|
||||||
|
OAuth {
|
||||||
|
access: String,
|
||||||
|
refresh: String,
|
||||||
|
expires: i64,
|
||||||
|
},
|
||||||
|
Api {
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthRecord {
|
||||||
|
/// True when an OAuth token is at or past `expires` (with a safety skew), given `now_ms`.
|
||||||
|
/// API keys and never-expiring OAuth tokens (`expires == 0`) are never considered expired.
|
||||||
|
pub fn is_expired(&self, now_ms: i64, skew_ms: i64) -> bool {
|
||||||
|
match self {
|
||||||
|
AuthRecord::OAuth { expires, .. } if *expires > 0 => now_ms + skew_ms >= *expires,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read/modify/write access to the auth file. Cheap to construct; every operation reloads so
|
||||||
|
/// concurrent writers (a refresh in one provider, a login in another) don't clobber each other.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthStorage {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthStorage {
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.local/share/ai-harness/auth.json` (falling back to the temp dir if there is no
|
||||||
|
/// data directory).
|
||||||
|
pub fn default_path() -> PathBuf {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
|
.join("ai-harness")
|
||||||
|
.join("auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_default_path() -> Self {
|
||||||
|
Self::new(Self::default_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all records. A missing file yields an empty map; a corrupt file is an error.
|
||||||
|
pub fn load(&self) -> io::Result<HashMap<String, AuthRecord>> {
|
||||||
|
match std::fs::read(&self.path) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(HashMap::new()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, provider: &str) -> io::Result<Option<AuthRecord>> {
|
||||||
|
Ok(self.load()?.remove(provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, provider: &str, record: AuthRecord) -> io::Result<()> {
|
||||||
|
let mut records = self.load()?;
|
||||||
|
records.insert(provider.to_string(), record);
|
||||||
|
self.write(&records)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, provider: &str) -> io::Result<()> {
|
||||||
|
let mut records = self.load()?;
|
||||||
|
if records.remove(provider).is_some() {
|
||||||
|
self.write(&records)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes `records` and writes them with `0600` permissions via a temp-file rename so
|
||||||
|
/// a partial write can never leave a truncated auth file behind.
|
||||||
|
fn write(&self, records: &HashMap<String, AuthRecord>) -> io::Result<()> {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_vec_pretty(records)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, &json)?;
|
||||||
|
set_owner_only(&tmp)?;
|
||||||
|
std::fs::rename(&tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_owner_only(path: &Path) -> io::Result<()> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_owner_only(_path: &Path) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn storage() -> (tempfile::TempDir, AuthStorage) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("nested").join("auth.json");
|
||||||
|
(dir, AuthStorage::new(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_loads_empty() {
|
||||||
|
let (_dir, store) = storage();
|
||||||
|
assert!(store.load().unwrap().is_empty());
|
||||||
|
assert_eq!(store.get("anthropic").unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_get_roundtrip_and_overwrite() {
|
||||||
|
let (_dir, store) = storage();
|
||||||
|
store
|
||||||
|
.set("anthropic", AuthRecord::Api { key: "k1".into() })
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.get("anthropic").unwrap(),
|
||||||
|
Some(AuthRecord::Api { key: "k1".into() })
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.set("anthropic", AuthRecord::Api { key: "k2".into() })
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.get("anthropic").unwrap(),
|
||||||
|
Some(AuthRecord::Api { key: "k2".into() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_providers_coexist() {
|
||||||
|
let (_dir, store) = storage();
|
||||||
|
store
|
||||||
|
.set("openai", AuthRecord::Api { key: "sk".into() })
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.set(
|
||||||
|
"github-copilot",
|
||||||
|
AuthRecord::OAuth {
|
||||||
|
access: "a".into(),
|
||||||
|
refresh: "r".into(),
|
||||||
|
expires: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let all = store.load().unwrap();
|
||||||
|
assert_eq!(all.len(), 2);
|
||||||
|
assert!(all.contains_key("openai"));
|
||||||
|
assert!(all.contains_key("github-copilot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_deletes_only_named_provider() {
|
||||||
|
let (_dir, store) = storage();
|
||||||
|
store
|
||||||
|
.set("openai", AuthRecord::Api { key: "sk".into() })
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.set("anthropic", AuthRecord::Api { key: "an".into() })
|
||||||
|
.unwrap();
|
||||||
|
store.remove("openai").unwrap();
|
||||||
|
assert_eq!(store.get("openai").unwrap(), None);
|
||||||
|
assert!(store.get("anthropic").unwrap().is_some());
|
||||||
|
// Removing an absent provider is a no-op, not an error.
|
||||||
|
store.remove("openai").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_expiry_respects_skew_and_never_expires() {
|
||||||
|
let never = AuthRecord::OAuth {
|
||||||
|
access: "a".into(),
|
||||||
|
refresh: "r".into(),
|
||||||
|
expires: 0,
|
||||||
|
};
|
||||||
|
assert!(!never.is_expired(i64::MAX, 0));
|
||||||
|
|
||||||
|
let expiring = AuthRecord::OAuth {
|
||||||
|
access: "a".into(),
|
||||||
|
refresh: "r".into(),
|
||||||
|
expires: 1_000,
|
||||||
|
};
|
||||||
|
assert!(!expiring.is_expired(800, 120));
|
||||||
|
assert!(expiring.is_expired(880, 120)); // within skew window
|
||||||
|
assert!(expiring.is_expired(1_000, 0));
|
||||||
|
|
||||||
|
assert!(!AuthRecord::Api { key: "k".into() }.is_expired(i64::MAX, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn file_is_written_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let (_dir, store) = storage();
|
||||||
|
store
|
||||||
|
.set("openai", AuthRecord::Api { key: "sk".into() })
|
||||||
|
.unwrap();
|
||||||
|
let mode = std::fs::metadata(&store.path).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
pub mod anthropic;
|
pub mod anthropic;
|
||||||
|
pub mod auth;
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|
||||||
pub use anthropic::AnthropicProvider;
|
pub use anthropic::AnthropicProvider;
|
||||||
|
pub use auth::{AuthRecord, AuthStorage};
|
||||||
pub use openai::OpenAiProvider;
|
pub use openai::OpenAiProvider;
|
||||||
pub use registry::ProviderRegistry;
|
pub use registry::ProviderRegistry;
|
||||||
|
|||||||
Reference in New Issue
Block a user