use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::config::{
expand_tilde, find_project_config_from, home_dir, merge_toml, ArrayMergeStrategy, Config,
};
use crate::error::{NewtError, Result};
pub const DEFAULT_AGENT_NAME: &str = "newt-agent[bot]";
pub const DEFAULT_AGENT_EMAIL: &str = "293447090+newt-agent[bot]@users.noreply.github.com";
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct SecretRef {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cmd: Option<String>,
}
impl SecretRef {
pub fn resolve(&self) -> Result<Option<Secret>> {
if let Some(var) = &self.env {
if let Ok(val) = std::env::var(var) {
let val = val.trim();
if !val.is_empty() {
return Ok(Some(Secret::new(val)));
}
}
return Ok(None);
}
if let Some(path) = &self.file {
let expanded = expand_tilde(path);
let contents = std::fs::read_to_string(&expanded).map_err(NewtError::Io)?;
if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
return Ok(Some(Secret::new(token)));
}
return Ok(None);
}
if let Some(cmd) = &self.cmd {
let output = shell_command(cmd).output().map_err(NewtError::Io)?;
if !output.status.success() {
return Err(NewtError::Config(format!(
"token command exited {}: {cmd}",
output.status
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(token) = stdout.lines().map(str::trim).find(|l| !l.is_empty()) {
return Ok(Some(Secret::new(token)));
}
return Ok(None);
}
Ok(None)
}
}
#[cfg(windows)]
fn shell_command(cmd: &str) -> Command {
let shell = std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into());
let mut command = Command::new(shell);
command.arg("/C").arg(cmd);
command
}
#[cfg(not(windows))]
fn shell_command(cmd: &str) -> Command {
let mut command = Command::new("sh");
command.arg("-c").arg(cmd);
command
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GithubApp {
pub app_id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installation_id: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename = "agent-identity")]
pub struct AgentIdentity {
pub name: String,
pub email: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_app: Option<GithubApp>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub tokens: std::collections::BTreeMap<String, SecretRef>,
}
impl Default for AgentIdentity {
fn default() -> Self {
Self {
name: DEFAULT_AGENT_NAME.to_string(),
email: DEFAULT_AGENT_EMAIL.to_string(),
signing_key: None,
public_key: None,
github_app: None,
tokens: std::collections::BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentitySource {
Workspace(PathBuf),
Home(PathBuf),
System(PathBuf),
Default,
}
impl IdentitySource {
#[must_use]
pub fn label(&self) -> String {
match self {
Self::Workspace(p) => format!("workspace ({})", p.display()),
Self::Home(p) => format!("home ({})", p.display()),
Self::System(p) => format!("system ({})", p.display()),
Self::Default => "compiled-in default (newt-agent[bot])".to_string(),
}
}
}
impl AgentIdentity {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(NewtError::Io)?;
Self::from_toml_str(&text)
}
pub fn from_toml_str(text: &str) -> Result<Self> {
let value: toml::Value =
toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))?;
Self::from_value(value)
}
fn from_value(value: toml::Value) -> Result<Self> {
let mut base =
toml::Value::try_from(Self::default()).map_err(|e| NewtError::Config(e.to_string()))?;
if let Some(section) = value.get("agent-identity").cloned() {
merge_toml(&mut base, section, ArrayMergeStrategy::Replace);
}
base.try_into()
.map_err(|e| NewtError::Config(e.to_string()))
}
pub fn resolve() -> Result<Self> {
Ok(Self::resolve_with_source()?.0)
}
pub fn resolve_with_source() -> Result<(Self, IdentitySource)> {
let cwd = std::env::current_dir().ok();
let home = home_dir();
let user_config_dir = Config::user_config_dir();
Self::resolve_from_dirs(cwd.as_deref(), home.as_deref(), user_config_dir.as_deref())
}
#[cfg(test)]
pub(crate) fn resolve_from(
cwd: Option<&Path>,
home: Option<&Path>,
) -> Result<(Self, IdentitySource)> {
let user_config_dir = home.map(|h| h.join(".newt"));
Self::resolve_from_dirs(cwd, home, user_config_dir.as_deref())
}
fn resolve_from_dirs(
cwd: Option<&Path>,
home: Option<&Path>,
user_config_dir: Option<&Path>,
) -> Result<(Self, IdentitySource)> {
if let Some(start) = cwd {
if let Some(cfg) = find_project_config_from(start, home) {
let candidate = cfg.with_file_name("agent-identity.toml");
if candidate.is_file() {
return Ok((
Self::load(&candidate)?,
IdentitySource::Workspace(candidate),
));
}
}
if let Some(found) = find_identity_walkup(start, home) {
return Ok((Self::load(&found)?, IdentitySource::Workspace(found)));
}
}
if let Some(dir) = user_config_dir {
let candidate = dir.join("agent-identity.toml");
if candidate.is_file() {
return Ok((Self::load(&candidate)?, IdentitySource::Home(candidate)));
}
}
let system = PathBuf::from("/etc/newt/agent-identity.toml");
if system.is_file() {
return Ok((Self::load(&system)?, IdentitySource::System(system)));
}
Ok((Self::default(), IdentitySource::Default))
}
#[must_use]
pub fn git_author(&self) -> (String, String) {
(self.name.clone(), self.email.clone())
}
#[must_use]
pub fn co_author_trailer(&self) -> String {
format!("Co-Authored-By: {} <{}>", self.name, self.email)
}
#[must_use]
pub fn signing_key_path(&self) -> Option<PathBuf> {
self.signing_key.as_deref().map(expand_tilde)
}
#[must_use]
pub fn public_key_path(&self) -> Option<PathBuf> {
self.public_key.as_deref().map(expand_tilde)
}
pub fn token(&self, name: &str) -> Result<Option<Secret>> {
match self.tokens.get(name) {
Some(r) => r.resolve(),
None => Ok(None),
}
}
#[must_use]
pub fn github_app(&self) -> Option<&GithubApp> {
self.github_app.as_ref()
}
}
fn find_identity_walkup(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
if home == Some(current) {
break;
}
let candidate = current.join(".newt").join("agent-identity.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = current.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_identity(dir: &Path, body: &str) -> PathBuf {
let newt = dir.join(".newt");
std::fs::create_dir_all(&newt).unwrap();
let path = newt.join("agent-identity.toml");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
f.flush().unwrap();
path
}
#[test]
fn default_is_newt_agent_bot() {
let id = AgentIdentity::default();
assert_eq!(id.name, "newt-agent[bot]");
assert_eq!(
id.email,
"293447090+newt-agent[bot]@users.noreply.github.com"
);
assert!(id.signing_key.is_none());
assert!(id.public_key.is_none());
assert!(id.github_app.is_none());
assert!(id.tokens.is_empty());
}
#[test]
fn resolve_with_no_files_yields_compiled_default() {
let cwd = TempDir::new().unwrap();
let home = TempDir::new().unwrap();
let (id, src) = AgentIdentity::resolve_from(Some(cwd.path()), Some(home.path())).unwrap();
assert_eq!(id, AgentIdentity::default());
assert_eq!(src, IdentitySource::Default);
}
#[test]
fn workspace_overrides_home_overrides_default() {
let home = TempDir::new().unwrap();
write_identity(
home.path(),
r#"
[agent-identity]
name = "home-agent[bot]"
email = "home@users.noreply.github.com"
"#,
);
let elsewhere = TempDir::new().unwrap();
let (id, src) =
AgentIdentity::resolve_from(Some(elsewhere.path()), Some(home.path())).unwrap();
assert_eq!(id.name, "home-agent[bot]");
assert!(matches!(src, IdentitySource::Home(_)));
let ws = TempDir::new().unwrap();
write_identity(
ws.path(),
r#"
[agent-identity]
name = "gilamonster-agent[bot]"
email = "293450354+gilamonster-agent[bot]@users.noreply.github.com"
"#,
);
let (id, src) = AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
assert_eq!(id.name, "gilamonster-agent[bot]");
assert_eq!(
id.email,
"293450354+gilamonster-agent[bot]@users.noreply.github.com"
);
assert!(matches!(src, IdentitySource::Workspace(_)));
}
#[test]
fn partial_file_inherits_default_email() {
let id = AgentIdentity::from_toml_str(
r#"
[agent-identity]
name = "custom[bot]"
"#,
)
.unwrap();
assert_eq!(id.name, "custom[bot]");
assert_eq!(id.email, DEFAULT_AGENT_EMAIL);
}
#[test]
fn co_author_trailer_format() {
let id = AgentIdentity::default();
assert_eq!(
id.co_author_trailer(),
"Co-Authored-By: newt-agent[bot] <293447090+newt-agent[bot]@users.noreply.github.com>"
);
}
#[test]
fn git_author_returns_name_and_email() {
let id = AgentIdentity::default();
let (name, email) = id.git_author();
assert_eq!(name, "newt-agent[bot]");
assert_eq!(email, DEFAULT_AGENT_EMAIL);
}
#[test]
fn signing_and_public_key_paths_expand_tilde() {
let id = AgentIdentity {
signing_key: Some("~/keys/id.pem".to_string()),
public_key: Some("~/keys/id.pub".to_string()),
..AgentIdentity::default()
};
let sk = id.signing_key_path().unwrap();
let pk = id.public_key_path().unwrap();
assert!(!sk.starts_with("~"));
assert!(sk.ends_with("keys/id.pem"));
assert!(pk.ends_with("keys/id.pub"));
assert!(AgentIdentity::default().signing_key_path().is_none());
assert!(AgentIdentity::default().public_key_path().is_none());
}
#[test]
fn token_resolves_from_env() {
let id = AgentIdentity::from_toml_str(
r#"
[agent-identity]
name = "x[bot]"
[agent-identity.tokens]
svc = { env = "NEWT_TEST_SVC_TOKEN_ENV" }
"#,
)
.unwrap();
unsafe { std::env::set_var("NEWT_TEST_SVC_TOKEN_ENV", "env-secret-value") };
let tok = id.token("svc").unwrap().unwrap();
assert_eq!(tok.expose(), "env-secret-value");
unsafe { std::env::remove_var("NEWT_TEST_SVC_TOKEN_ENV") };
assert!(id.token("svc").unwrap().is_none());
assert!(id.token("nope").unwrap().is_none());
}
#[test]
fn token_resolves_from_file() {
let dir = TempDir::new().unwrap();
let secret_path = dir.path().join("tok");
std::fs::write(&secret_path, "\n file-secret-value \n").unwrap();
let id = AgentIdentity::from_toml_str(&format!(
r#"
[agent-identity]
name = "x[bot]"
[agent-identity.tokens]
svc = {{ file = '{}' }}
"#,
secret_path.display()
))
.unwrap();
let tok = id.token("svc").unwrap().unwrap();
assert_eq!(tok.expose(), "file-secret-value");
}
#[test]
fn token_resolves_from_cmd() {
let cmd = if cfg!(windows) {
"echo cmd-secret-value"
} else {
"printf 'cmd-secret-value\\n'"
};
let id = AgentIdentity::from_toml_str(&format!(
r#"
[agent-identity]
name = "x[bot]"
[agent-identity.tokens]
svc = {{ cmd = "{cmd}" }}
"#,
))
.unwrap();
let tok = id.token("svc").unwrap().unwrap();
assert_eq!(tok.expose(), "cmd-secret-value");
}
#[test]
fn token_cmd_failure_is_error_not_panic() {
let cmd = if cfg!(windows) { "exit /B 3" } else { "exit 3" };
let id = AgentIdentity::from_toml_str(&format!(
r#"
[agent-identity]
name = "x[bot]"
[agent-identity.tokens]
svc = {{ cmd = "{cmd}" }}
"#,
))
.unwrap();
let err = id.token("svc").unwrap_err();
assert!(format!("{err}").contains("token command exited"));
}
#[test]
fn github_app_surfaces_public_coordinates_and_key_path() {
let id = AgentIdentity::from_toml_str(
r#"
[agent-identity]
name = "x[bot]"
[agent-identity.github_app]
app_id = 4046825
client_id = "Iv23li5iPGv4awNHpHbZ"
installation_id = 140120359
private_key = "~/.vault-secrets/agents/x/app.pem"
"#,
)
.unwrap();
let app = id.github_app().unwrap();
assert_eq!(app.app_id, 4046825);
assert_eq!(app.client_id.as_deref(), Some("Iv23li5iPGv4awNHpHbZ"));
assert_eq!(app.installation_id, Some(140120359));
assert_eq!(
app.private_key.as_deref(),
Some("~/.vault-secrets/agents/x/app.pem")
);
assert!(AgentIdentity::default().github_app().is_none());
}
#[test]
fn round_trips_through_toml_with_no_raw_secret_field() {
let dir = TempDir::new().unwrap();
let secret_path = dir.path().join("tok");
std::fs::write(&secret_path, "should-not-appear-in-toml").unwrap();
let id = AgentIdentity::from_toml_str(&format!(
r#"
[agent-identity]
name = "round[bot]"
email = "round@users.noreply.github.com"
signing_key = "~/keys/id.pem"
public_key = "~/keys/id.pub"
[agent-identity.github_app]
app_id = 42
[agent-identity.tokens]
svc = {{ file = '{}' }}
"#,
secret_path.display()
))
.unwrap();
let mut section = std::collections::BTreeMap::new();
section.insert("agent-identity".to_string(), id.clone());
let text = toml::to_string_pretty(§ion).unwrap();
assert!(!text.contains("should-not-appear-in-toml"));
assert!(text.contains("file"));
let back = AgentIdentity::from_toml_str(&text).unwrap();
assert_eq!(back, id);
}
#[test]
fn secret_debug_is_redacted() {
let s = Secret::new("super-secret");
assert_eq!(format!("{s:?}"), "Secret(<redacted>)");
assert_eq!(s.expose(), "super-secret");
}
#[test]
fn identity_source_labels_are_human_readable() {
assert!(IdentitySource::Default.label().contains("newt-agent[bot]"));
assert!(
IdentitySource::Workspace(PathBuf::from("/w/.newt/agent-identity.toml"))
.label()
.contains("workspace")
);
assert!(
IdentitySource::Home(PathBuf::from("/h/.newt/agent-identity.toml"))
.label()
.contains("home")
);
assert!(
IdentitySource::System(PathBuf::from("/etc/newt/agent-identity.toml"))
.label()
.contains("system")
);
}
#[test]
fn standalone_workspace_identity_without_sibling_config_resolves() {
let home = TempDir::new().unwrap();
let ws = TempDir::new().unwrap();
write_identity(
ws.path(),
r#"
[agent-identity]
name = "standalone[bot]"
"#,
);
let (id, src) = AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
assert_eq!(id.name, "standalone[bot]");
assert!(matches!(src, IdentitySource::Workspace(_)));
}
}