1use std::path::{Path, PathBuf};
36use std::process::Command;
37
38use serde::{Deserialize, Serialize};
39
40use crate::config::{
41 expand_tilde, find_project_config_from, home_dir, merge_toml, ArrayMergeStrategy, Config,
42};
43use crate::error::{NewtError, Result};
44
45pub const DEFAULT_AGENT_NAME: &str = "newt-agent[bot]";
51pub const DEFAULT_AGENT_EMAIL: &str = "293447090+newt-agent[bot]@users.noreply.github.com";
53
54#[derive(Clone, PartialEq, Eq)]
66pub struct Secret(String);
67
68impl Secret {
69 #[must_use]
71 pub fn new(value: impl Into<String>) -> Self {
72 Self(value.into())
73 }
74
75 #[must_use]
78 pub fn expose(&self) -> &str {
79 &self.0
80 }
81}
82
83impl std::fmt::Debug for Secret {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.write_str("Secret(<redacted>)")
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
105#[serde(default, deny_unknown_fields)]
106pub struct SecretRef {
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub env: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub file: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub cmd: Option<String>,
116}
117
118impl SecretRef {
119 pub fn resolve(&self) -> Result<Option<Secret>> {
127 if let Some(var) = &self.env {
128 if let Ok(val) = std::env::var(var) {
129 let val = val.trim();
130 if !val.is_empty() {
131 return Ok(Some(Secret::new(val)));
132 }
133 }
134 return Ok(None);
135 }
136 if let Some(path) = &self.file {
137 let expanded = expand_tilde(path);
138 let contents = std::fs::read_to_string(&expanded).map_err(NewtError::Io)?;
139 if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
140 return Ok(Some(Secret::new(token)));
141 }
142 return Ok(None);
143 }
144 if let Some(cmd) = &self.cmd {
145 let output = shell_command(cmd).output().map_err(NewtError::Io)?;
146 if !output.status.success() {
147 return Err(NewtError::Config(format!(
148 "token command exited {}: {cmd}",
149 output.status
150 )));
151 }
152 let stdout = String::from_utf8_lossy(&output.stdout);
153 if let Some(token) = stdout.lines().map(str::trim).find(|l| !l.is_empty()) {
154 return Ok(Some(Secret::new(token)));
155 }
156 return Ok(None);
157 }
158 Ok(None)
159 }
160}
161
162#[cfg(windows)]
163fn shell_command(cmd: &str) -> Command {
164 let shell = std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into());
165 let mut command = Command::new(shell);
166 command.arg("/C").arg(cmd);
167 command
168}
169
170#[cfg(not(windows))]
171fn shell_command(cmd: &str) -> Command {
172 let mut command = Command::new("sh");
173 command.arg("-c").arg(cmd);
174 command
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct GithubApp {
189 pub app_id: u64,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub client_id: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub installation_id: Option<u64>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub private_key: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename = "agent-identity")]
215pub struct AgentIdentity {
216 pub name: String,
218 pub email: String,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub signing_key: Option<String>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub public_key: Option<String>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub github_app: Option<GithubApp>,
239
240 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
244 pub tokens: std::collections::BTreeMap<String, SecretRef>,
245}
246
247impl Default for AgentIdentity {
248 fn default() -> Self {
252 Self {
253 name: DEFAULT_AGENT_NAME.to_string(),
254 email: DEFAULT_AGENT_EMAIL.to_string(),
255 signing_key: None,
256 public_key: None,
257 github_app: None,
258 tokens: std::collections::BTreeMap::new(),
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum IdentitySource {
268 Workspace(PathBuf),
270 Home(PathBuf),
272 System(PathBuf),
274 Default,
276}
277
278impl IdentitySource {
279 #[must_use]
281 pub fn label(&self) -> String {
282 match self {
283 Self::Workspace(p) => format!("workspace ({})", p.display()),
284 Self::Home(p) => format!("home ({})", p.display()),
285 Self::System(p) => format!("system ({})", p.display()),
286 Self::Default => "compiled-in default (newt-agent[bot])".to_string(),
287 }
288 }
289}
290
291impl AgentIdentity {
292 pub fn load(path: &Path) -> Result<Self> {
294 let text = std::fs::read_to_string(path).map_err(NewtError::Io)?;
295 Self::from_toml_str(&text)
296 }
297
298 pub fn from_toml_str(text: &str) -> Result<Self> {
304 let value: toml::Value =
305 toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))?;
306 Self::from_value(value)
307 }
308
309 fn from_value(value: toml::Value) -> Result<Self> {
312 let mut base =
315 toml::Value::try_from(Self::default()).map_err(|e| NewtError::Config(e.to_string()))?;
316 if let Some(section) = value.get("agent-identity").cloned() {
317 merge_toml(&mut base, section, ArrayMergeStrategy::Replace);
318 }
319 base.try_into()
320 .map_err(|e| NewtError::Config(e.to_string()))
321 }
322
323 pub fn resolve() -> Result<Self> {
335 Ok(Self::resolve_with_source()?.0)
336 }
337
338 pub fn resolve_with_source() -> Result<(Self, IdentitySource)> {
341 let cwd = std::env::current_dir().ok();
342 let home = home_dir();
343 let user_config_dir = Config::user_config_dir();
344 Self::resolve_from_dirs(cwd.as_deref(), home.as_deref(), user_config_dir.as_deref())
345 }
346
347 #[cfg(test)]
350 pub(crate) fn resolve_from(
351 cwd: Option<&Path>,
352 home: Option<&Path>,
353 ) -> Result<(Self, IdentitySource)> {
354 let user_config_dir = home.map(|h| h.join(".newt"));
355 Self::resolve_from_dirs(cwd, home, user_config_dir.as_deref())
356 }
357
358 fn resolve_from_dirs(
359 cwd: Option<&Path>,
360 home: Option<&Path>,
361 user_config_dir: Option<&Path>,
362 ) -> Result<(Self, IdentitySource)> {
363 if let Some(start) = cwd {
365 if let Some(cfg) = find_project_config_from(start, home) {
366 let candidate = cfg.with_file_name("agent-identity.toml");
369 if candidate.is_file() {
370 return Ok((
371 Self::load(&candidate)?,
372 IdentitySource::Workspace(candidate),
373 ));
374 }
375 }
376 if let Some(found) = find_identity_walkup(start, home) {
380 return Ok((Self::load(&found)?, IdentitySource::Workspace(found)));
381 }
382 }
383
384 if let Some(dir) = user_config_dir {
386 let candidate = dir.join("agent-identity.toml");
387 if candidate.is_file() {
388 return Ok((Self::load(&candidate)?, IdentitySource::Home(candidate)));
389 }
390 }
391
392 let system = PathBuf::from("/etc/newt/agent-identity.toml");
394 if system.is_file() {
395 return Ok((Self::load(&system)?, IdentitySource::System(system)));
396 }
397
398 Ok((Self::default(), IdentitySource::Default))
400 }
401
402 #[must_use]
408 pub fn git_author(&self) -> (String, String) {
409 (self.name.clone(), self.email.clone())
410 }
411
412 #[must_use]
414 pub fn co_author_trailer(&self) -> String {
415 format!("Co-Authored-By: {} <{}>", self.name, self.email)
416 }
417
418 #[must_use]
421 pub fn signing_key_path(&self) -> Option<PathBuf> {
422 self.signing_key.as_deref().map(expand_tilde)
423 }
424
425 #[must_use]
427 pub fn public_key_path(&self) -> Option<PathBuf> {
428 self.public_key.as_deref().map(expand_tilde)
429 }
430
431 pub fn token(&self, name: &str) -> Result<Option<Secret>> {
436 match self.tokens.get(name) {
437 Some(r) => r.resolve(),
438 None => Ok(None),
439 }
440 }
441
442 #[must_use]
445 pub fn github_app(&self) -> Option<&GithubApp> {
446 self.github_app.as_ref()
447 }
448}
449
450fn find_identity_walkup(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
453 let mut dir = Some(start);
454 while let Some(current) = dir {
455 if home == Some(current) {
456 break;
457 }
458 let candidate = current.join(".newt").join("agent-identity.toml");
459 if candidate.is_file() {
460 return Some(candidate);
461 }
462 dir = current.parent();
463 }
464 None
465}
466
467#[cfg(test)]
472mod tests {
473 use super::*;
474 use std::io::Write;
475 use tempfile::TempDir;
476
477 fn write_identity(dir: &Path, body: &str) -> PathBuf {
478 let newt = dir.join(".newt");
479 std::fs::create_dir_all(&newt).unwrap();
480 let path = newt.join("agent-identity.toml");
481 let mut f = std::fs::File::create(&path).unwrap();
482 f.write_all(body.as_bytes()).unwrap();
483 f.flush().unwrap();
484 path
485 }
486
487 #[test]
488 fn default_is_newt_agent_bot() {
489 let id = AgentIdentity::default();
490 assert_eq!(id.name, "newt-agent[bot]");
491 assert_eq!(
492 id.email,
493 "293447090+newt-agent[bot]@users.noreply.github.com"
494 );
495 assert!(id.signing_key.is_none());
496 assert!(id.public_key.is_none());
497 assert!(id.github_app.is_none());
498 assert!(id.tokens.is_empty());
499 }
500
501 #[test]
502 fn resolve_with_no_files_yields_compiled_default() {
503 let cwd = TempDir::new().unwrap();
506 let home = TempDir::new().unwrap();
507 let (id, src) = AgentIdentity::resolve_from(Some(cwd.path()), Some(home.path())).unwrap();
508 assert_eq!(id, AgentIdentity::default());
509 assert_eq!(src, IdentitySource::Default);
510 }
511
512 #[test]
513 fn workspace_overrides_home_overrides_default() {
514 let home = TempDir::new().unwrap();
515 write_identity(
516 home.path(),
517 r#"
518[agent-identity]
519name = "home-agent[bot]"
520email = "home@users.noreply.github.com"
521"#,
522 );
523
524 let elsewhere = TempDir::new().unwrap();
526 let (id, src) =
527 AgentIdentity::resolve_from(Some(elsewhere.path()), Some(home.path())).unwrap();
528 assert_eq!(id.name, "home-agent[bot]");
529 assert!(matches!(src, IdentitySource::Home(_)));
530
531 let ws = TempDir::new().unwrap();
533 write_identity(
534 ws.path(),
535 r#"
536[agent-identity]
537name = "gilamonster-agent[bot]"
538email = "293450354+gilamonster-agent[bot]@users.noreply.github.com"
539"#,
540 );
541 let (id, src) = AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
542 assert_eq!(id.name, "gilamonster-agent[bot]");
543 assert_eq!(
544 id.email,
545 "293450354+gilamonster-agent[bot]@users.noreply.github.com"
546 );
547 assert!(matches!(src, IdentitySource::Workspace(_)));
548 }
549
550 #[test]
551 fn partial_file_inherits_default_email() {
552 let id = AgentIdentity::from_toml_str(
554 r#"
555[agent-identity]
556name = "custom[bot]"
557"#,
558 )
559 .unwrap();
560 assert_eq!(id.name, "custom[bot]");
561 assert_eq!(id.email, DEFAULT_AGENT_EMAIL);
562 }
563
564 #[test]
565 fn co_author_trailer_format() {
566 let id = AgentIdentity::default();
567 assert_eq!(
568 id.co_author_trailer(),
569 "Co-Authored-By: newt-agent[bot] <293447090+newt-agent[bot]@users.noreply.github.com>"
570 );
571 }
572
573 #[test]
574 fn git_author_returns_name_and_email() {
575 let id = AgentIdentity::default();
576 let (name, email) = id.git_author();
577 assert_eq!(name, "newt-agent[bot]");
578 assert_eq!(email, DEFAULT_AGENT_EMAIL);
579 }
580
581 #[test]
582 fn signing_and_public_key_paths_expand_tilde() {
583 let id = AgentIdentity {
584 signing_key: Some("~/keys/id.pem".to_string()),
585 public_key: Some("~/keys/id.pub".to_string()),
586 ..AgentIdentity::default()
587 };
588 let sk = id.signing_key_path().unwrap();
589 let pk = id.public_key_path().unwrap();
590 assert!(!sk.starts_with("~"));
591 assert!(sk.ends_with("keys/id.pem"));
592 assert!(pk.ends_with("keys/id.pub"));
593 assert!(AgentIdentity::default().signing_key_path().is_none());
595 assert!(AgentIdentity::default().public_key_path().is_none());
596 }
597
598 #[test]
599 fn token_resolves_from_env() {
600 let id = AgentIdentity::from_toml_str(
601 r#"
602[agent-identity]
603name = "x[bot]"
604[agent-identity.tokens]
605svc = { env = "NEWT_TEST_SVC_TOKEN_ENV" }
606"#,
607 )
608 .unwrap();
609 unsafe { std::env::set_var("NEWT_TEST_SVC_TOKEN_ENV", "env-secret-value") };
611 let tok = id.token("svc").unwrap().unwrap();
612 assert_eq!(tok.expose(), "env-secret-value");
613 unsafe { std::env::remove_var("NEWT_TEST_SVC_TOKEN_ENV") };
614 assert!(id.token("svc").unwrap().is_none());
616 assert!(id.token("nope").unwrap().is_none());
618 }
619
620 #[test]
621 fn token_resolves_from_file() {
622 let dir = TempDir::new().unwrap();
623 let secret_path = dir.path().join("tok");
624 std::fs::write(&secret_path, "\n file-secret-value \n").unwrap();
625 let id = AgentIdentity::from_toml_str(&format!(
630 r#"
631[agent-identity]
632name = "x[bot]"
633[agent-identity.tokens]
634svc = {{ file = '{}' }}
635"#,
636 secret_path.display()
637 ))
638 .unwrap();
639 let tok = id.token("svc").unwrap().unwrap();
640 assert_eq!(tok.expose(), "file-secret-value");
641 }
642
643 #[test]
644 fn token_resolves_from_cmd() {
645 let cmd = if cfg!(windows) {
646 "echo cmd-secret-value"
647 } else {
648 "printf 'cmd-secret-value\\n'"
649 };
650 let id = AgentIdentity::from_toml_str(&format!(
651 r#"
652[agent-identity]
653name = "x[bot]"
654[agent-identity.tokens]
655svc = {{ cmd = "{cmd}" }}
656"#,
657 ))
658 .unwrap();
659 let tok = id.token("svc").unwrap().unwrap();
660 assert_eq!(tok.expose(), "cmd-secret-value");
661 }
662
663 #[test]
664 fn token_cmd_failure_is_error_not_panic() {
665 let cmd = if cfg!(windows) { "exit /B 3" } else { "exit 3" };
666 let id = AgentIdentity::from_toml_str(&format!(
667 r#"
668[agent-identity]
669name = "x[bot]"
670[agent-identity.tokens]
671svc = {{ cmd = "{cmd}" }}
672"#,
673 ))
674 .unwrap();
675 let err = id.token("svc").unwrap_err();
676 assert!(format!("{err}").contains("token command exited"));
677 }
678
679 #[test]
680 fn github_app_surfaces_public_coordinates_and_key_path() {
681 let id = AgentIdentity::from_toml_str(
682 r#"
683[agent-identity]
684name = "x[bot]"
685[agent-identity.github_app]
686app_id = 4046825
687client_id = "Iv23li5iPGv4awNHpHbZ"
688installation_id = 140120359
689private_key = "~/.vault-secrets/agents/x/app.pem"
690"#,
691 )
692 .unwrap();
693 let app = id.github_app().unwrap();
694 assert_eq!(app.app_id, 4046825);
695 assert_eq!(app.client_id.as_deref(), Some("Iv23li5iPGv4awNHpHbZ"));
696 assert_eq!(app.installation_id, Some(140120359));
697 assert_eq!(
699 app.private_key.as_deref(),
700 Some("~/.vault-secrets/agents/x/app.pem")
701 );
702 assert!(AgentIdentity::default().github_app().is_none());
704 }
705
706 #[test]
707 fn round_trips_through_toml_with_no_raw_secret_field() {
708 let dir = TempDir::new().unwrap();
712 let secret_path = dir.path().join("tok");
713 std::fs::write(&secret_path, "should-not-appear-in-toml").unwrap();
714 let id = AgentIdentity::from_toml_str(&format!(
717 r#"
718[agent-identity]
719name = "round[bot]"
720email = "round@users.noreply.github.com"
721signing_key = "~/keys/id.pem"
722public_key = "~/keys/id.pub"
723[agent-identity.github_app]
724app_id = 42
725[agent-identity.tokens]
726svc = {{ file = '{}' }}
727"#,
728 secret_path.display()
729 ))
730 .unwrap();
731
732 let mut section = std::collections::BTreeMap::new();
735 section.insert("agent-identity".to_string(), id.clone());
736 let text = toml::to_string_pretty(§ion).unwrap();
737
738 assert!(!text.contains("should-not-appear-in-toml"));
740 assert!(text.contains("file"));
742
743 let back = AgentIdentity::from_toml_str(&text).unwrap();
745 assert_eq!(back, id);
746 }
747
748 #[test]
749 fn secret_debug_is_redacted() {
750 let s = Secret::new("super-secret");
751 assert_eq!(format!("{s:?}"), "Secret(<redacted>)");
752 assert_eq!(s.expose(), "super-secret");
753 }
754
755 #[test]
756 fn identity_source_labels_are_human_readable() {
757 assert!(IdentitySource::Default.label().contains("newt-agent[bot]"));
758 assert!(
759 IdentitySource::Workspace(PathBuf::from("/w/.newt/agent-identity.toml"))
760 .label()
761 .contains("workspace")
762 );
763 assert!(
764 IdentitySource::Home(PathBuf::from("/h/.newt/agent-identity.toml"))
765 .label()
766 .contains("home")
767 );
768 assert!(
769 IdentitySource::System(PathBuf::from("/etc/newt/agent-identity.toml"))
770 .label()
771 .contains("system")
772 );
773 }
774
775 #[test]
776 fn standalone_workspace_identity_without_sibling_config_resolves() {
777 let home = TempDir::new().unwrap();
780 let ws = TempDir::new().unwrap();
781 write_identity(
782 ws.path(),
783 r#"
784[agent-identity]
785name = "standalone[bot]"
786"#,
787 );
788 let (id, src) = AgentIdentity::resolve_from(Some(ws.path()), Some(home.path())).unwrap();
789 assert_eq!(id.name, "standalone[bot]");
790 assert!(matches!(src, IdentitySource::Workspace(_)));
791 }
792}