use std::{
fs,
io::Write,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use agent_infra_sdk::{
InfraClient,
runtime_identity_contract::{
CreateRecoveryCredentialRequest, RecoveryCredentialId, RevokeRecoveryCredentialRequest,
RotateRecoveryCredentialRequest,
},
};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Subcommand, ValueEnum};
use serde_json::json;
use crate::{CommandOutput, parse_runtime_id};
const SERVICE: &str = "agent-infra.runtime-recovery";
#[derive(Debug, Subcommand)]
pub enum RuntimeCredentialCommand {
Ensure(EnsureArgs),
Exec(ExecArgs),
List { runtime_id: String },
Rotate {
runtime_id: String,
credential_id: String,
#[command(flatten)]
store: StoreArgs,
},
Revoke {
runtime_id: String,
credential_id: String,
},
}
#[derive(Debug, Args)]
pub struct EnsureArgs {
pub runtime_id: String,
#[arg(long)]
pub name: Option<String>,
#[command(flatten)]
pub store: StoreArgs,
}
#[derive(Debug, Args)]
pub struct ExecArgs {
pub runtime_id: String,
#[arg(long)]
pub name: Option<String>,
#[command(flatten)]
pub store: StoreArgs,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
pub command: Vec<String>,
}
#[derive(Debug, Args)]
pub struct StoreArgs {
#[arg(long, value_enum, default_value_t = CredentialStoreKind::Keychain)]
pub store: CredentialStoreKind,
#[arg(long)]
pub yes: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum CredentialStoreKind {
Keychain,
File,
}
pub(crate) async fn execute(
client: &InfraClient,
gateway: &str,
command: RuntimeCredentialCommand,
) -> Result<CommandOutput> {
match command {
RuntimeCredentialCommand::Ensure(args) => execute_ensure(client, gateway, args).await,
RuntimeCredentialCommand::Exec(args) => execute_exec(client, gateway, args).await,
RuntimeCredentialCommand::List { runtime_id } => {
let runtime_id = parse_runtime_id(&runtime_id)?;
CommandOutput::json(
client
.runtime_identity()
.list_recovery_credentials(&runtime_id)
.await?,
)
}
RuntimeCredentialCommand::Rotate {
runtime_id,
credential_id,
store,
} => {
let runtime_id = parse_runtime_id(&runtime_id)?;
let credential_id =
RecoveryCredentialId::new(credential_id).map_err(|error| anyhow!(error))?;
let issued = client
.runtime_identity()
.rotate_recovery_credential(
&runtime_id,
&credential_id,
&RotateRecoveryCredentialRequest {
idempotency_key: String::new(),
},
)
.await?;
let secret = issued
.secret
.ok_or_else(|| anyhow!("rotate did not return a recovery secret"))?;
let name = issued.credential.name.clone();
save_secret(gateway, runtime_id.as_str(), &name, &secret, &store)?;
CommandOutput::json(json!({
"stored": true,
"runtimeId": runtime_id.as_str(),
"credentialId": issued.credential.id.as_str(),
"name": name,
"predecessorId": issued.credential.predecessor_id.as_ref().map(|id| id.as_str().to_string()),
}))
}
RuntimeCredentialCommand::Revoke {
runtime_id,
credential_id,
} => {
let runtime_id = parse_runtime_id(&runtime_id)?;
let credential_id =
RecoveryCredentialId::new(credential_id).map_err(|error| anyhow!(error))?;
CommandOutput::json(
client
.runtime_identity()
.revoke_recovery_credential(
&runtime_id,
&credential_id,
&RevokeRecoveryCredentialRequest {
idempotency_key: String::new(),
reason: None,
},
)
.await?,
)
}
}
}
async fn execute_ensure(
client: &InfraClient,
gateway: &str,
args: EnsureArgs,
) -> Result<CommandOutput> {
let runtime_id = parse_runtime_id(&args.runtime_id)?;
let name = args.name.unwrap_or_else(default_credential_name);
if load_secret(gateway, runtime_id.as_str(), &name, &args.store)?.is_some() {
return CommandOutput::json(json!({
"stored": true,
"reused": true,
"runtimeId": runtime_id.as_str(),
"name": name,
}));
}
let issued = client
.runtime_identity()
.create_recovery_credential(
&runtime_id,
&CreateRecoveryCredentialRequest {
name: name.clone(),
purpose: "local-dev".into(),
ceiling: Default::default(),
idempotency_key: String::new(),
},
)
.await?;
let secret = issued.secret.ok_or_else(|| {
anyhow!(
"recovery credential already exists remotely but the local store is empty; rotate it with `infra runtime credential rotate`"
)
})?;
save_secret(
gateway,
runtime_id.as_str(),
&issued.credential.name,
&secret,
&args.store,
)?;
CommandOutput::json(json!({
"stored": true,
"reused": false,
"runtimeId": runtime_id.as_str(),
"credentialId": issued.credential.id.as_str(),
"name": issued.credential.name,
}))
}
async fn execute_exec(
client: &InfraClient,
gateway: &str,
args: ExecArgs,
) -> Result<CommandOutput> {
let runtime_id = parse_runtime_id(&args.runtime_id)?;
let name = args.name.clone().unwrap_or_else(default_credential_name);
let secret = match load_secret(gateway, runtime_id.as_str(), &name, &args.store)? {
Some(secret) => secret,
None => {
execute_ensure(
client,
gateway,
EnsureArgs {
runtime_id: args.runtime_id.clone(),
name: Some(name.clone()),
store: StoreArgs {
store: args.store.store,
yes: args.store.yes,
},
},
)
.await?;
load_secret(gateway, runtime_id.as_str(), &name, &args.store)?
.ok_or_else(|| anyhow!("failed to load the recovery credential after ensure"))?
}
};
let mut command = args.command.into_iter();
let program = command
.next()
.ok_or_else(|| anyhow!("credential exec requires a command"))?;
let status = Command::new(program)
.args(command)
.env("AGENT_INFRA_RECOVERY_CREDENTIAL", secret)
.env("AGENT_INFRA_RUNTIME_ID", runtime_id.as_str())
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("failed to spawn command")?;
std::process::exit(status.code().unwrap_or(1));
}
fn default_credential_name() -> String {
let host = hostname();
let sanitized = host
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
let sanitized = sanitized.trim_matches('-');
if sanitized.is_empty() {
"dev-local".into()
} else {
format!("dev-{sanitized}")
}
}
fn hostname() -> String {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
Command::new("hostname")
.arg("-s")
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "local".into())
})
}
fn account(gateway: &str, runtime_id: &str, name: &str) -> String {
let host = url::Url::parse(gateway)
.ok()
.and_then(|url| url.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| gateway.to_string());
format!("{host}|{runtime_id}|{name}")
}
fn load_secret(
gateway: &str,
runtime_id: &str,
name: &str,
store: &StoreArgs,
) -> Result<Option<String>> {
let account = account(gateway, runtime_id, name);
match store.store {
CredentialStoreKind::Keychain => match keychain_read(&account) {
Ok(secret) => Ok(secret),
Err(_) if cfg!(target_os = "macos") => Ok(None),
Err(_) => load_file_secret(&account),
},
CredentialStoreKind::File => load_file_secret(&account),
}
}
fn save_secret(
gateway: &str,
runtime_id: &str,
name: &str,
secret: &str,
store: &StoreArgs,
) -> Result<()> {
let account = account(gateway, runtime_id, name);
match store.store {
CredentialStoreKind::Keychain => {
if keychain_write(&account, secret).is_ok() {
return Ok(());
}
if !store.yes && !cfg!(target_os = "macos") {
bail!(
"OS keychain is unavailable; pass --store file --yes to write a 0600 file under ~/.agent-infra/recovery-credentials"
);
}
save_file_secret(&account, secret)
}
CredentialStoreKind::File => {
if !store.yes {
bail!("writing a recovery credential file requires --yes");
}
save_file_secret(&account, secret)
}
}
}
fn keychain_read(account: &str) -> Result<Option<String>> {
let output = Command::new("security")
.args(["find-generic-password", "-s", SERVICE, "-a", account, "-w"])
.output();
let output = match output {
Ok(output) => output,
Err(_) => return Ok(None),
};
if !output.status.success() {
return Ok(None);
}
let secret = String::from_utf8(output.stdout)?.trim().to_string();
if secret.is_empty() {
Ok(None)
} else {
Ok(Some(secret))
}
}
fn keychain_write(account: &str, secret: &str) -> Result<()> {
let status = Command::new("security")
.args([
"add-generic-password",
"-U",
"-s",
SERVICE,
"-a",
account,
"-w",
secret,
])
.status()
.context("failed to invoke macOS keychain")?;
if status.success() {
Ok(())
} else {
bail!("macOS keychain rejected the recovery credential")
}
}
fn credentials_dir() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME is required for file credential storage")?;
Ok(PathBuf::from(home)
.join(".agent-infra")
.join("recovery-credentials"))
}
fn file_path(account: &str) -> Result<PathBuf> {
let encoded = account.replace(['/', ':'], "_");
Ok(credentials_dir()?.join(encoded))
}
fn load_file_secret(account: &str) -> Result<Option<String>> {
let path = file_path(account)?;
if !path.exists() {
return Ok(None);
}
let secret = fs::read_to_string(&path)?.trim().to_string();
Ok((!secret.is_empty()).then_some(secret))
}
fn save_file_secret(account: &str, secret: &str) -> Result<()> {
let dir = credentials_dir()?;
fs::create_dir_all(&dir)?;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
let path = file_path(account)?;
write_secret_file(&path, secret)
}
fn write_secret_file(path: &Path, secret: &str) -> Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
file.write_all(secret.as_bytes())?;
file.write_all(b"\n")?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn file_store_uses_0600_permissions() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("secret");
write_secret_file(&path, "rti_rc_abc.secret").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert!(!format!("{:?}", path).contains("rti_rc_abc.secret"));
}
#[test]
fn default_name_is_stable_and_safe() {
let name = default_credential_name();
assert!(name.starts_with("dev-"));
assert!(
name.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
);
}
}