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
}
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));
}
}