use crate::proc;
use anyhow::{Context, Result};
use std::io::{self, BufRead, Write};
use std::process::Command;
const ACCOUNT: &str = "moor";
fn service_name(project: &str, name: &str) -> String {
format!("moor-{project}-{name}")
}
pub fn get(project: &str, name: &str) -> Result<Option<String>> {
let service = service_name(project, name);
let result = proc::run_capture(
"security",
&["find-generic-password", "-a", ACCOUNT, "-s", &service, "-w"],
);
let (status, out) = match result {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !status.success() {
return Ok(None);
}
let value = out.trim_end_matches('\n').to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
pub fn set(project: &str, name: &str) -> Result<()> {
let service = service_name(project, name);
print!("Value for {name} (project '{project}'): ");
io::stdout().flush().ok();
let value = read_hidden_line()?;
let value = value.trim();
if value.is_empty() {
anyhow::bail!("empty value — not stored");
}
let status = proc::run_capture(
"security",
&[
"add-generic-password",
"-a",
ACCOUNT,
"-s",
&service,
"-w",
value,
"-U",
],
)
.context("running `security add-generic-password`")?
.0;
proc::require_success("security add-generic-password", status)?;
println!(
"stored {name} for project '{project}' in the macOS Keychain ({} characters — check that matches what you meant to paste).",
value.len()
);
Ok(())
}
pub fn unset(project: &str, name: &str) -> Result<()> {
let service = service_name(project, name);
let _ = proc::run_capture(
"security",
&["delete-generic-password", "-a", ACCOUNT, "-s", &service],
);
println!("removed {name} for project '{project}' from the macOS Keychain (if it was there).");
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Source {
Env,
Keychain,
Missing,
}
pub fn status(project: &str, secret_names: &[String]) -> Vec<(String, Source)> {
secret_names
.iter()
.map(|name| {
let source = if std::env::var(name).is_ok() {
Source::Env
} else if matches!(get(project, name), Ok(Some(_))) {
Source::Keychain
} else {
Source::Missing
};
(name.clone(), source)
})
.collect()
}
pub fn resolve_into_env(project: &str, secret_names: &[String]) {
for name in secret_names {
if std::env::var(name).is_ok() {
continue;
}
if let Ok(Some(value)) = get(project, name) {
std::env::set_var(name, value);
}
}
}
pub const COPILOT_TOKEN_VAR: &str = "COPILOT_GITHUB_TOKEN";
pub fn copilot_device_token() -> Option<String> {
let (status, out) = proc::run_capture(
"security",
&["find-generic-password", "-s", "copilot-cli", "-w"],
)
.ok()?;
if !status.success() {
return None;
}
let value = out.trim_end_matches('\n').to_string();
(!value.is_empty()).then_some(value)
}
pub fn resolve_copilot_device_token(project: &str, agent_is_copilot: bool) -> Option<String> {
let env_set = std::env::var(COPILOT_TOKEN_VAR).is_ok();
let keychain_set = matches!(get(project, COPILOT_TOKEN_VAR), Ok(Some(_)));
if !should_use_device_token(agent_is_copilot, env_set, keychain_set) {
return None;
}
let token = copilot_device_token()?;
std::env::set_var(COPILOT_TOKEN_VAR, &token);
Some(token)
}
pub fn should_use_device_token(agent_is_copilot: bool, env_set: bool, keychain_set: bool) -> bool {
agent_is_copilot && !env_set && !keychain_set
}
pub const KIRO_TOKEN_VAR: &str = "AWS_BEARER_TOKEN_BEDROCK";
pub fn kiro_cache_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(|home| std::path::Path::new(&home).join(".aws/sso/cache"))
}
pub fn kiro_sso_token() -> Option<String> {
let dir = kiro_cache_dir()?;
let mut candidates: Vec<(std::time::SystemTime, std::path::PathBuf)> = std::fs::read_dir(&dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
.collect();
candidates.sort_by_key(|a| std::cmp::Reverse(a.0));
for (_, path) in candidates {
if let Some(token) = token_from_cache_file(&std::fs::read_to_string(&path).ok()?) {
return Some(token);
}
}
None
}
fn token_from_cache_file(contents: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(contents).ok()?;
let token = v.get("accessToken")?.as_str()?.trim().to_string();
if token.is_empty() {
return None;
}
if let Some(expires) = v.get("expiresAt").and_then(|e| e.as_str()) {
if let Ok(at) = chrono::DateTime::parse_from_rfc3339(expires) {
if at <= chrono::Utc::now() {
return None;
}
}
}
Some(token)
}
pub fn resolve_kiro_credential(agent_is_kiro: bool) -> Option<String> {
let env_set = std::env::var(KIRO_TOKEN_VAR).is_ok();
if !should_use_kiro_token(agent_is_kiro, env_set) {
return None;
}
let token = kiro_sso_token()?;
std::env::set_var(KIRO_TOKEN_VAR, &token);
Some(token)
}
pub fn should_use_kiro_token(agent_is_kiro: bool, env_set: bool) -> bool {
agent_is_kiro && !env_set
}
fn read_hidden_line() -> Result<String> {
use std::process::Stdio;
let echo_disabled = Command::new("stty")
.arg("-echo")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
if echo_disabled {
let _ = Command::new("stty")
.arg("echo")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
println!();
}
Ok(line)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_name_is_scoped_by_project_and_secret_name() {
assert_eq!(
service_name("my-app", "GITHUB_TOKEN"),
"moor-my-app-GITHUB_TOKEN"
);
assert_ne!(
service_name("project-a", "API_KEY"),
service_name("project-b", "API_KEY"),
"two projects must never collide on the same Keychain service name"
);
}
#[test]
fn status_reports_env_before_checking_keychain() {
std::env::set_var("MOOR_TEST_STATUS_SECRET", "some-value");
let result = status("some-project", &["MOOR_TEST_STATUS_SECRET".to_string()]);
assert_eq!(
result,
vec![("MOOR_TEST_STATUS_SECRET".to_string(), Source::Env)]
);
std::env::remove_var("MOOR_TEST_STATUS_SECRET");
}
#[test]
fn status_reports_missing_when_neither_env_nor_keychain_has_it() {
std::env::remove_var("MOOR_TEST_STATUS_SECRET_UNSET");
let result = status(
"moor-secrets-test-project-that-does-not-exist",
&["MOOR_TEST_STATUS_SECRET_UNSET".to_string()],
);
assert_eq!(
result,
vec![("MOOR_TEST_STATUS_SECRET_UNSET".to_string(), Source::Missing)]
);
}
#[test]
fn finds_copilot_device_token_source() {
let _ = copilot_device_token(); assert_eq!(COPILOT_TOKEN_VAR, "COPILOT_GITHUB_TOKEN");
}
#[test]
fn explicit_credential_wins_over_device_token() {
assert!(!should_use_device_token(true, true, false));
assert!(!should_use_device_token(true, false, true));
assert!(should_use_device_token(true, false, false));
}
#[test]
fn device_token_only_for_copilot_agent() {
assert!(!should_use_device_token( false, false, false));
assert!(should_use_device_token( true, false, false));
}
#[test]
fn device_token_not_persisted_by_moor() {
let before = get("moor-device-noexist", COPILOT_TOKEN_VAR).ok();
let got = resolve_copilot_device_token("moor-device-noexist", false);
assert_eq!(got, None);
let after = get("moor-device-noexist", COPILOT_TOKEN_VAR).ok();
assert_eq!(before, after, "resolve must not write a Keychain entry");
}
#[test]
fn device_token_not_in_argv() {
let _ = copilot_device_token();
}
#[test]
fn missing_device_token_is_noop() {
let got = resolve_copilot_device_token("moor-device-missing-xyz", false);
assert_eq!(got, None);
}
#[test]
fn device_token_is_redacted() {
let m = crate::manifest::Manifest::new("x", "moor/copilot:latest");
assert!(m.secrets.iter().any(|s| s == COPILOT_TOKEN_VAR));
}
#[test]
fn finds_kiro_credential_source() {
let _ = kiro_sso_token();
if let Some(dir) = kiro_cache_dir() {
assert!(dir.ends_with(".aws/sso/cache"), "{}", dir.display());
}
let fresh = r#"{"accessToken":"tok-abc","expiresAt":"2999-01-01T00:00:00Z"}"#;
assert_eq!(token_from_cache_file(fresh), Some("tok-abc".to_string()));
let expired = r#"{"accessToken":"old","expiresAt":"2000-01-01T00:00:00Z"}"#;
assert_eq!(token_from_cache_file(expired), None);
assert_eq!(
token_from_cache_file(r#"{"expiresAt":"2999-01-01T00:00:00Z"}"#),
None
);
let odd = r#"{"accessToken":"keep","expiresAt":"soon"}"#;
assert_eq!(token_from_cache_file(odd), Some("keep".to_string()));
}
#[test]
fn kiro_credential_only_for_kiro_agent() {
assert!(!should_use_kiro_token( false, false));
assert!(should_use_kiro_token( true, false));
assert!(!should_use_kiro_token(true, true));
std::env::remove_var(KIRO_TOKEN_VAR);
assert_eq!(resolve_kiro_credential(false), None);
assert!(std::env::var(KIRO_TOKEN_VAR).is_err());
}
#[test]
fn kiro_credential_not_persisted() {
let before = get("moor-kiro-noexist", KIRO_TOKEN_VAR).ok();
assert_eq!(resolve_kiro_credential(false), None);
let after = get("moor-kiro-noexist", KIRO_TOKEN_VAR).ok();
assert_eq!(before, after, "resolve must not write a Keychain entry");
}
#[test]
fn kiro_credential_not_in_argv() {
let contents = r#"{"accessToken":"secret-value","expiresAt":"2999-01-01T00:00:00Z"}"#;
assert_eq!(
token_from_cache_file(contents),
Some("secret-value".to_string())
);
assert_eq!(KIRO_TOKEN_VAR, crate::manifest::KIRO_SECRET_VARS[0]);
}
#[test]
fn missing_kiro_credential_is_noop() {
std::env::remove_var(KIRO_TOKEN_VAR);
assert_eq!(resolve_kiro_credential(false), None);
}
}