use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::config::{
expand_tilde, find_project_config_from, find_project_file_from, home_dir, merge_toml,
ArrayMergeStrategy, Config,
};
use crate::error::{NewtError, Result};
pub const DEFAULT_AGENT_NAME: &str = "newt-agent";
pub const DEFAULT_AGENT_EMAIL: &str = "309460085+newt-agent@users.noreply.github.com";
pub const GITHUB_APP_BOT_NAME: &str = "newt-agent[bot]";
pub const GITHUB_APP_BOT_EMAIL: &str = "293447090+newt-agent[bot]@users.noreply.github.com";
pub const AGENT_IDENTITY_FILENAME: &str = "agent-identity.toml";
#[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 model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_email: Option<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>,
#[serde(default, skip_serializing_if = "GitProfile::is_default")]
pub git: GitProfile,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct GitProfile {
pub author: SandboxAuthor,
pub config_source: GitConfigSource,
pub signing: SigningMode,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub config: std::collections::BTreeMap<String, String>,
}
impl GitProfile {
fn is_default(&self) -> bool {
self == &Self::default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SandboxAuthor {
#[default]
Agent,
Operator,
}
impl SandboxAuthor {
pub const ALL: [Self; 2] = [Self::Agent, Self::Operator];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Operator => "operator",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SigningMode {
#[default]
Off,
Harness,
Operator,
}
impl SigningMode {
pub const ALL: [Self; 3] = [Self::Off, Self::Harness, Self::Operator];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Harness => "harness",
Self::Operator => "operator",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GitConfigSource {
#[default]
Baseline,
Environment,
}
impl GitConfigSource {
pub const ALL: [Self; 2] = [Self::Baseline, Self::Environment];
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Baseline => "baseline",
Self::Environment => "environment",
}
}
}
impl Default for AgentIdentity {
fn default() -> Self {
Self {
name: DEFAULT_AGENT_NAME.to_string(),
email: DEFAULT_AGENT_EMAIL.to_string(),
model: None,
operator: None,
operator_email: None,
signing_key: None,
public_key: None,
github_app: None,
tokens: std::collections::BTreeMap::new(),
git: GitProfile::default(),
}
}
}
pub fn default_operator() -> Option<String> {
let from_git = std::process::Command::new("git")
.args(["config", "--get", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
from_git
.or_else(|| {
std::env::var("GIT_AUTHOR_NAME")
.ok()
.filter(|s| !s.is_empty())
})
.or_else(|| std::env::var("USER").ok().filter(|s| !s.is_empty()))
}
fn read_git_config(key: &str) -> Option<String> {
std::process::Command::new("git")
.args(["config", "--get", key])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn host_operator_identity() -> (Option<String>, Option<String>) {
let name = read_git_config("user.name");
let email = read_git_config("user.email");
match (name, email) {
(Some(n), Some(e)) => (Some(n), Some(e)),
(Some(n), None) => (Some(n), None),
(None, _) => {
(default_operator(), None)
}
}
}
fn resolve_operator_identity(
cfg_name: Option<&str>,
cfg_email: Option<&str>,
host: (Option<String>, Option<String>),
) -> (Option<String>, Option<String>) {
let cfg_name = cfg_name.map(str::trim).filter(|s| !s.is_empty());
let cfg_email = cfg_email.map(str::trim).filter(|s| !s.is_empty());
if let (Some(name), Some(email)) = (cfg_name, cfg_email) {
return (Some(name.to_string()), Some(email.to_string()));
}
if let Some(name) = cfg_name {
return (Some(name.to_string()), None);
}
match host {
(Some(n), Some(e)) => (Some(n), Some(e)),
(Some(n), None) => (Some(n), None),
(None, _) => (None, None),
}
}
#[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)".to_string(),
}
}
#[must_use]
pub fn configure_hint(&self) -> Option<&'static str> {
match self {
Self::Default => Some(
"Override: write ~/.newt/agent-identity.toml (or `newt identity set --name … --email …`). \
A future setup dialog will use the same path.",
),
_ => None,
}
}
}
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)
}
#[must_use]
pub fn user_identity_path() -> Option<PathBuf> {
Config::user_config_dir().map(|dir| dir.join(AGENT_IDENTITY_FILENAME))
}
pub fn to_toml_string(&self) -> Result<String> {
#[derive(Serialize)]
struct File<'a> {
#[serde(rename = "agent-identity")]
identity: &'a AgentIdentity,
}
toml::to_string_pretty(&File { identity: self })
.map_err(|e| NewtError::Config(e.to_string()))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
}
let text = self.to_toml_string()?;
std::fs::write(path, text).map_err(NewtError::Io)
}
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 untrusted_workspace(mut self) -> Self {
self.git = GitProfile::default();
self.signing_key = None;
self.public_key = None;
self.github_app = None;
self.tokens.clear();
self.operator = None;
self.operator_email = None;
self
}
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_FILENAME);
if candidate.is_file() {
return Ok((
Self::load(&candidate)?.untrusted_workspace(),
IdentitySource::Workspace(candidate),
));
}
}
if let Some(found) = find_project_file_from(start, home, AGENT_IDENTITY_FILENAME) {
return Ok((
Self::load(&found)?.untrusted_workspace(),
IdentitySource::Workspace(found),
));
}
}
if let Some(dir) = user_config_dir {
let candidate = dir.join(AGENT_IDENTITY_FILENAME);
if candidate.is_file() {
return Ok((Self::load(&candidate)?, IdentitySource::Home(candidate)));
}
}
let system = PathBuf::from("/etc/newt").join(AGENT_IDENTITY_FILENAME);
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 operator_name(&self) -> Option<String> {
self.operator_identity().0
}
#[must_use]
pub fn operator_email(&self) -> Option<String> {
self.operator_identity().1
}
#[must_use]
pub fn sandbox_author(&self) -> (String, String) {
if self.git.author == SandboxAuthor::Operator {
if let (Some(name), Some(email)) = self.operator_identity() {
return (name, email);
}
}
(self.name.clone(), self.email.clone())
}
#[must_use]
pub fn operator_identity(&self) -> (Option<String>, Option<String>) {
resolve_operator_identity(
self.operator.as_deref(),
self.operator_email.as_deref(),
host_operator_identity(),
)
}
#[must_use]
pub fn co_author_trailer(&self) -> String {
crate::attribution::Attribution::new(
self.model.as_deref().unwrap_or("<model>"),
crate::build_info::harness_name(),
crate::build_info::PACKAGE_VERSION,
self.email.clone(),
)
.with_build(crate::build_info::SOURCE_ID)
.trailer()
}
#[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()
}
}
#[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_FILENAME);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(body.as_bytes()).unwrap();
f.flush().unwrap();
path
}
fn workspace_under(home: &TempDir) -> TempDir {
tempfile::tempdir_in(home.path()).unwrap()
}
#[test]
fn to_toml_string_wraps_agent_identity_table() {
let id = AgentIdentity {
name: "custom-agent".into(),
email: "custom@users.noreply.github.com".into(),
..AgentIdentity::default()
};
let text = id.to_toml_string().unwrap();
assert!(
text.contains("[agent-identity]"),
"must use the loadable table name: {text}"
);
assert!(text.contains("name = \"custom-agent\""));
assert!(text.contains("email = \"custom@users.noreply.github.com\""));
let loaded = AgentIdentity::from_toml_str(&text).unwrap();
assert_eq!(loaded.name, "custom-agent");
assert_eq!(loaded.email, "custom@users.noreply.github.com");
}
#[test]
fn save_writes_file_that_resolve_from_home_picks_up() {
let home = TempDir::new().unwrap();
let elsewhere = workspace_under(&home);
let path = home.path().join(".newt").join(AGENT_IDENTITY_FILENAME);
let id = AgentIdentity {
name: "saved-agent".into(),
email: "saved@users.noreply.github.com".into(),
..AgentIdentity::default()
};
id.save(&path).unwrap();
assert!(path.is_file());
let (resolved, src) =
AgentIdentity::resolve_from(Some(elsewhere.path()), Some(home.path())).unwrap();
assert_eq!(resolved.name, "saved-agent");
assert_eq!(resolved.email, "saved@users.noreply.github.com");
assert!(matches!(src, IdentitySource::Home(_)));
}
#[test]
fn configure_hint_only_for_compiled_default() {
assert!(IdentitySource::Default.configure_hint().is_some());
assert!(
IdentitySource::Home(PathBuf::from("/h/.newt/agent-identity.toml"))
.configure_hint()
.is_none()
);
}
#[test]
fn default_is_newt_agent_github_user() {
let id = AgentIdentity::default();
assert_eq!(id.name, "newt-agent");
assert_eq!(id.email, "309460085+newt-agent@users.noreply.github.com");
assert_ne!(id.name, GITHUB_APP_BOT_NAME);
assert_ne!(id.email, GITHUB_APP_BOT_EMAIL);
assert!(id.signing_key.is_none());
assert!(id.public_key.is_none());
assert!(id.github_app.is_none());
assert!(id.tokens.is_empty());
assert!(id.model.is_none());
}
#[test]
fn resolve_with_no_files_yields_compiled_default() {
let home = TempDir::new().unwrap();
let cwd = workspace_under(&home);
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 = workspace_under(&home);
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 = workspace_under(&home);
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]
#[serial_test::serial(newt_brand_name_env)]
fn co_author_trailer_format() {
unsafe { std::env::remove_var("NEWT_BRAND_NAME") };
let id = AgentIdentity::default();
assert_eq!(
id.co_author_trailer(),
format!(
"Co-authored-by: <model> (v{} {}) <309460085+newt-agent@users.noreply.github.com>",
crate::build_info::PACKAGE_VERSION,
crate::build_info::SOURCE_ID
)
);
}
#[test]
#[serial_test::serial(newt_brand_name_env)]
fn co_author_trailer_with_model() {
unsafe { std::env::remove_var("NEWT_BRAND_NAME") };
let id = AgentIdentity {
model: Some("sakamakismile/Ornith-1.0-35B-NVFP4".to_string()),
..AgentIdentity::default()
};
assert_eq!(
id.co_author_trailer(),
format!(
"Co-authored-by: sakamakismile/Ornith-1.0-35B-NVFP4 (v{} {}) <309460085+newt-agent@users.noreply.github.com>",
crate::build_info::PACKAGE_VERSION,
crate::build_info::SOURCE_ID
)
);
}
#[test]
fn git_author_returns_name_and_email() {
let id = AgentIdentity::default();
let (name, email) = id.git_author();
assert_eq!(name, "newt-agent");
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"));
assert!(!IdentitySource::Default.label().contains("[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]
#[cfg(unix)]
fn home_identity_survives_a_symlinked_home() {
let root = TempDir::new().unwrap();
let real_home = root.path().join("real-home");
let ws = real_home.join("ws");
std::fs::create_dir_all(&ws).unwrap();
write_identity(
&real_home,
r#"
[agent-identity]
name = "keyed-agent[bot]"
signing_key = "/nonexistent/vault/path/identity.pem"
"#,
);
write_identity(
root.path(),
r#"
[agent-identity]
name = "above-home[bot]"
"#,
);
let linked_home = root.path().join("linked-home");
std::os::unix::fs::symlink(&real_home, &linked_home).unwrap();
let (id, src) = AgentIdentity::resolve_from(Some(&ws), Some(&linked_home)).unwrap();
assert_eq!(id.name, "keyed-agent[bot]");
assert!(!matches!(src, IdentitySource::Workspace(_)), "{src:?}");
assert!(
id.signing_key.is_some(),
"a home file keeps its signing key"
);
}
#[test]
fn standalone_workspace_identity_without_sibling_config_resolves() {
let home = TempDir::new().unwrap();
let ws = workspace_under(&home);
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(_)));
}
const OPERATOR_AUTHOR_TOML: &str = r#"
[agent-identity]
name = "agent[bot]"
email = "agent@example.test"
operator = "Op Erator"
operator_email = "op@example.test"
[agent-identity.git]
author = "operator"
"#;
#[test]
fn workspace_identity_cannot_set_sandbox_author_to_operator() {
let home = TempDir::new().unwrap();
let ws = workspace_under(&home);
write_identity(ws.path(), OPERATOR_AUTHOR_TOML);
let (id, src) = AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
assert!(matches!(src, IdentitySource::Workspace(_)));
assert_eq!(
id.sandbox_author(),
("agent[bot]".to_string(), "agent@example.test".to_string())
);
}
#[test]
fn operator_config_may_set_sandbox_author_to_operator() {
let home = TempDir::new().unwrap();
let elsewhere = workspace_under(&home);
write_identity(home.path(), OPERATOR_AUTHOR_TOML);
let (id, src) =
AgentIdentity::resolve_from(Some(elsewhere.path()), Some(home.path())).unwrap();
assert!(matches!(src, IdentitySource::Home(_)));
assert_eq!(
id.sandbox_author(),
("Op Erator".to_string(), "op@example.test".to_string())
);
}
fn signing_toml(mode: &str, key: &Path) -> String {
format!(
"[agent-identity]\nname = \"a[bot]\"\nsigning_key = '{}'\noperator = \"Forged Op\"\n\n[agent-identity.git]\nsigning = \"{mode}\"\n",
key.display()
)
}
#[test]
fn workspace_identity_cannot_select_a_signer_or_key() {
let home = TempDir::new().unwrap();
let ws = workspace_under(&home);
let key = ws.path().join("repo-key.pem");
crate::commit_signing::generate_harness_key(&key).unwrap();
for mode in ["harness", "operator"] {
write_identity(ws.path(), &signing_toml(mode, &key));
let (id, src) =
AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
assert!(matches!(src, IdentitySource::Workspace(_)));
assert_eq!(id.signing_key, None, "{mode}: key path survived");
assert_eq!(id.operator, None, "{mode}: operator name survived");
assert!(
crate::commit_signing::session_signer(&id).is_none(),
"{mode}: a workspace file selected a signer"
);
}
}
#[test]
fn operator_home_identity_may_select_the_harness_signer() {
let home = TempDir::new().unwrap();
let elsewhere = workspace_under(&home);
let key = home.path().join("harness.pem");
crate::commit_signing::generate_harness_key(&key).unwrap();
write_identity(home.path(), &signing_toml("harness", &key));
let (id, src) =
AgentIdentity::resolve_from(Some(elsewhere.path()), Some(home.path())).unwrap();
assert!(matches!(src, IdentitySource::Home(_)));
assert!(crate::commit_signing::session_signer(&id).is_some());
assert_eq!(id.operator.as_deref(), Some("Forged Op"));
}
#[test]
fn resolve_configured_pair_wins_outright() {
let (name, email) = resolve_operator_identity(
Some("shawn"),
Some("shawn@configured.example"),
(Some("Host Name".into()), Some("host@host.example".into())),
);
assert_eq!(name.as_deref(), Some("shawn"));
assert_eq!(email.as_deref(), Some("shawn@configured.example"));
}
#[test]
fn resolve_configured_name_without_email_is_never_paired_with_host_email() {
let (name, email) = resolve_operator_identity(
Some("shawn"),
None,
(Some("Host Name".into()), Some("host@host.example".into())),
);
assert_eq!(name.as_deref(), Some("shawn"));
assert!(
email.is_none(),
"configured name must not pair with host email"
);
}
#[test]
fn resolve_configured_email_without_name_falls_to_host_pair() {
let (name, email) = resolve_operator_identity(
None,
Some("shawn@configured.example"),
(Some("Host Name".into()), Some("host@host.example".into())),
);
assert_eq!(name.as_deref(), Some("Host Name"));
assert_eq!(email.as_deref(), Some("host@host.example"));
}
#[test]
fn resolve_unconfigured_uses_matched_host_pair() {
let (name, email) = resolve_operator_identity(
None,
None,
(Some("Host Name".into()), Some("host@host.example".into())),
);
assert_eq!(name.as_deref(), Some("Host Name"));
assert_eq!(email.as_deref(), Some("host@host.example"));
}
#[test]
fn resolve_host_name_only_keeps_name_emits_no_email() {
let (name, email) = resolve_operator_identity(None, None, (Some("Host Name".into()), None));
assert_eq!(name.as_deref(), Some("Host Name"));
assert!(email.is_none());
}
#[test]
fn resolve_nothing_known_yields_none_pair() {
let (name, email) = resolve_operator_identity(None, None, (None, None));
assert!(name.is_none());
assert!(email.is_none());
}
#[test]
fn resolve_host_email_without_host_name_drops_the_email() {
let (name, email) =
resolve_operator_identity(None, None, (None, Some("host@host.example".into())));
assert!(name.is_none());
assert!(
email.is_none(),
"an email with no matched name is not a pair"
);
}
#[test]
fn resolve_trims_whitespace_and_ignores_empty_configured_values() {
let (name, email) = resolve_operator_identity(
Some(" shawn "),
Some(" "),
(Some("Host Name".into()), Some("host@host.example".into())),
);
assert_eq!(name.as_deref(), Some("shawn"));
assert!(
email.is_none(),
"blank configured email is absent — no pairing"
);
}
#[test]
fn operator_identity_configured_pair_via_toml() {
let id = AgentIdentity::from_toml_str(
r#"
[agent-identity]
name = "newt-agent"
operator = "shawn"
operator_email = "shawn@configured.example"
"#,
)
.unwrap();
let (name, email) = id.operator_identity();
assert_eq!(name.as_deref(), Some("shawn"));
assert_eq!(email.as_deref(), Some("shawn@configured.example"));
}
#[test]
fn operator_identity_configured_name_only_emits_no_email() {
let id = AgentIdentity::from_toml_str(
r#"
[agent-identity]
name = "newt-agent"
operator = "shawn"
"#,
)
.unwrap();
let (name, email) = id.operator_identity();
assert_eq!(name.as_deref(), Some("shawn"));
assert!(
email.is_none(),
"configured name with no configured email emits no email (never paired with host)"
);
assert_eq!(id.operator_name().as_deref(), Some("shawn"));
assert!(id.operator_email().is_none());
}
#[test]
fn operator_email_never_invented_when_unset() {
let id = AgentIdentity::default();
assert!(id.operator.is_none());
assert!(id.operator_email.is_none());
let email = id.operator_email();
assert!(
email.is_none() || email.unwrap().contains('@'),
"operator email is real-or-None, never invented"
);
}
}