use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{RelayError, Result};
use crate::{CreateAgentRequest, RelayCast, RelayCastOptions};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCredentials {
pub workspace_id: String,
pub agent_id: String,
pub api_key: String,
pub agent_name: Option<String>,
pub agent_token: Option<String>,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct AgentSession {
pub credentials: AgentCredentials,
pub token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NameConflictStrategy {
#[default]
RotateExisting,
RetryWithSuffixOnce,
Fail,
}
#[derive(Debug, Clone, Default)]
pub struct BootstrapConfig {
pub preferred_name: Option<String>,
pub agent_type: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub conflict_strategy: NameConflictStrategy,
}
pub struct CredentialStore {
path: PathBuf,
}
impl CredentialStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Option<AgentCredentials> {
let data = fs::read(&self.path).ok()?;
serde_json::from_slice(&data).ok()
}
pub fn save(&self, creds: &AgentCredentials) -> Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|e| {
RelayError::InvalidResponse(format!("failed to create credential directory: {e}"))
})?;
}
let data = serde_json::to_vec_pretty(creds)?;
fs::write(&self.path, &data).map_err(|e| {
RelayError::InvalidResponse(format!("failed to write credentials: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
let _ = fs::set_permissions(&self.path, perms);
}
Ok(())
}
}
fn now_iso8601() -> String {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days_since_epoch = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut year = 1970i64;
let mut remaining_days = days_since_epoch as i64;
loop {
let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
366
} else {
365
};
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
let days_in_months: [i64; 12] = [
31,
if is_leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 1u32;
for &dim in &days_in_months {
if remaining_days < dim {
break;
}
remaining_days -= dim;
month += 1;
}
let day = remaining_days + 1;
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
pub async fn bootstrap_session(
store: &CredentialStore,
config: BootstrapConfig,
) -> Result<AgentSession> {
let cached = store.load();
let base_url = config.base_url.as_deref();
if let Some(ref creds) = cached {
if let Some(ref cached_name) = creds.agent_name {
let preferred = config.preferred_name.as_deref().unwrap_or(cached_name);
if cached_name == preferred {
let relay = build_relay(&creds.api_key, base_url)?;
match relay.rotate_agent_token(cached_name).await {
Ok(result) => {
let session = finish_session(
store,
creds.workspace_id.clone(),
creds.agent_id.clone(),
creds.api_key.clone(),
Some(cached_name.clone()),
result.token,
)?;
return Ok(session);
}
Err(e) if e.is_not_found() || e.is_auth_rejection() => {
}
Err(e) if e.is_rate_limited() => {
if let Some(ref token) = creds.agent_token {
return Ok(AgentSession {
credentials: creds.clone(),
token: token.clone(),
});
}
return Err(e);
}
Err(e) => return Err(e),
}
}
}
}
let (api_key, workspace_id) = if let Some(ref key) = config.api_key {
(key.clone(), cached.as_ref().map(|c| c.workspace_id.clone()))
} else if let Some(ref creds) = cached {
if creds.api_key.starts_with("rk_") {
(creds.api_key.clone(), Some(creds.workspace_id.clone()))
} else {
create_fresh_workspace(base_url).await?
}
} else {
create_fresh_workspace(base_url).await?
};
let relay = build_relay(&api_key, base_url)?;
let cached_name = cached.as_ref().and_then(|c| c.agent_name.clone());
let cached_agent_id = cached
.as_ref()
.map(|c| c.agent_id.clone())
.unwrap_or_default();
let name = config
.preferred_name
.or(cached_name)
.unwrap_or_else(|| format!("agent-{}", &uuid_v4_short()));
let agent_type = config.agent_type.unwrap_or_else(|| "agent".into());
let conflict_strategy = config.conflict_strategy;
match relay
.register_agent(CreateAgentRequest {
name: name.clone(),
agent_type: Some(agent_type.clone()),
persona: None,
metadata: None,
})
.await
{
Ok(result) => {
let ws_id = workspace_id.unwrap_or_default();
finish_session(
store,
ws_id,
result.id,
api_key,
Some(result.name),
result.token,
)
}
Err(e) if e.is_conflict() => match conflict_strategy {
NameConflictStrategy::RotateExisting => {
let rotate_result = relay.rotate_agent_token(&name).await?;
let ws_id = workspace_id.unwrap_or_default();
finish_session(
store,
ws_id,
cached_agent_id,
api_key,
Some(name),
rotate_result.token,
)
}
NameConflictStrategy::RetryWithSuffixOnce => {
let suffix_name = format!("{}-{}", name, uuid_v4_short());
let retried = relay
.register_agent(CreateAgentRequest {
name: suffix_name.clone(),
agent_type: Some(agent_type),
persona: None,
metadata: None,
})
.await;
match retried {
Ok(result) => {
let ws_id = workspace_id.unwrap_or_default();
finish_session(
store,
ws_id,
result.id,
api_key,
Some(result.name),
result.token,
)
}
Err(err) if err.is_conflict() => Err(RelayError::api(
"agent_already_exists",
format!("agent name '{}' already exists after retry", suffix_name),
409,
)),
Err(err) => Err(err),
}
}
NameConflictStrategy::Fail => Err(RelayError::api(
"agent_already_exists",
format!("agent name '{}' already exists", name),
409,
)),
},
Err(e) if e.is_auth_rejection() => {
let (fresh_key, fresh_ws_id) = create_fresh_workspace(base_url).await?;
let fresh_relay = build_relay(&fresh_key, base_url)?;
let result = fresh_relay
.register_agent(CreateAgentRequest {
name: name.clone(),
agent_type: Some("agent".into()),
persona: None,
metadata: None,
})
.await?;
let ws_id = fresh_ws_id.unwrap_or_default();
finish_session(
store,
ws_id,
result.id,
fresh_key,
Some(result.name),
result.token,
)
}
Err(e) => Err(e),
}
}
async fn create_fresh_workspace(base_url: Option<&str>) -> Result<(String, Option<String>)> {
let ws_name = format!("relay-{}", &uuid_v4_short());
let result = RelayCast::create_workspace(&ws_name, base_url).await?;
Ok((result.api_key, Some(result.workspace_id)))
}
fn build_relay(api_key: &str, base_url: Option<&str>) -> Result<RelayCast> {
let mut opts = RelayCastOptions::new(api_key);
if let Some(url) = base_url {
opts = opts.with_base_url(url);
}
RelayCast::new(opts)
}
fn finish_session(
store: &CredentialStore,
workspace_id: String,
agent_id: String,
api_key: String,
agent_name: Option<String>,
token: String,
) -> Result<AgentSession> {
let creds = AgentCredentials {
workspace_id,
agent_id,
api_key,
agent_name,
agent_token: Some(token.clone()),
updated_at: now_iso8601(),
};
store.save(&creds)?;
Ok(AgentSession {
credentials: creds,
token,
})
}
fn uuid_v4_short() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.hash(&mut hasher);
std::thread::current().id().hash(&mut hasher);
format!("{:016x}", hasher.finish())[..8].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ok(data: serde_json::Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(json!({ "ok": true, "data": data }))
}
fn api_error(status: u16, code: &str, message: &str) -> ResponseTemplate {
ResponseTemplate::new(status).set_body_json(json!({
"ok": false,
"error": {
"code": code,
"message": message
}
}))
}
#[test]
fn credential_store_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = CredentialStore::new(dir.path().join("creds.json"));
let creds = AgentCredentials {
workspace_id: "ws_123".into(),
agent_id: "a_456".into(),
api_key: "rk_live_test".into(),
agent_name: Some("test-agent".into()),
agent_token: Some("at_live_token".into()),
updated_at: "2025-01-01T00:00:00Z".into(),
};
store.save(&creds).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.workspace_id, "ws_123");
assert_eq!(loaded.agent_id, "a_456");
assert_eq!(loaded.api_key, "rk_live_test");
assert_eq!(loaded.agent_name.as_deref(), Some("test-agent"));
assert_eq!(loaded.agent_token.as_deref(), Some("at_live_token"));
}
#[test]
fn load_missing_file_returns_none() {
let store = CredentialStore::new("/tmp/nonexistent-relaycast-test.json");
assert!(store.load().is_none());
}
#[test]
fn load_corrupt_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
fs::write(&path, "not-json").unwrap();
let store = CredentialStore::new(path);
assert!(store.load().is_none());
}
#[test]
fn now_iso8601_produces_valid_format() {
let ts = now_iso8601();
assert!(ts.contains('T'));
assert!(ts.ends_with('Z'));
assert_eq!(ts.len(), 20); }
#[cfg(unix)]
#[test]
fn saved_file_has_restricted_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = CredentialStore::new(dir.path().join("creds.json"));
let creds = AgentCredentials {
workspace_id: "ws".into(),
agent_id: "a".into(),
api_key: "rk".into(),
agent_name: None,
agent_token: None,
updated_at: "2025-01-01T00:00:00Z".into(),
};
store.save(&creds).unwrap();
let perms = fs::metadata(store.path()).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[tokio::test]
async fn conflict_strategy_fail_returns_conflict_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agents"))
.and(body_string_contains("\"name\":\"lead\""))
.respond_with(api_error(409, "agent_already_exists", "name taken"))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let store = CredentialStore::new(dir.path().join("creds.json"));
let result = bootstrap_session(
&store,
BootstrapConfig {
preferred_name: Some("lead".to_string()),
api_key: Some("rk_live_test".to_string()),
base_url: Some(server.uri()),
conflict_strategy: NameConflictStrategy::Fail,
..Default::default()
},
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.is_conflict());
}
#[tokio::test]
async fn conflict_strategy_retry_with_suffix_registers_new_name() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agents"))
.and(body_string_contains("\"name\":\"lead\""))
.respond_with(api_error(409, "agent_already_exists", "name taken"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/agents"))
.and(body_string_contains("\"name\":\"lead-"))
.respond_with(ok(json!({
"id": "a_retry",
"name": "lead-suffixed",
"token": "at_live_retry",
"status": "online",
"created_at": "2026-01-01T00:00:00.000Z"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let store = CredentialStore::new(dir.path().join("creds.json"));
let session = bootstrap_session(
&store,
BootstrapConfig {
preferred_name: Some("lead".to_string()),
api_key: Some("rk_live_test".to_string()),
base_url: Some(server.uri()),
conflict_strategy: NameConflictStrategy::RetryWithSuffixOnce,
..Default::default()
},
)
.await
.expect("bootstrap with suffix retry should succeed");
assert_eq!(session.token, "at_live_retry");
assert_eq!(
session.credentials.agent_name.as_deref(),
Some("lead-suffixed")
);
}
}