Skip to main content

newt_core/
agent_identity.rs

1//! Agent commit identity (`.newt/agent-identity.toml`).
2//!
3//! This is the configurable **commit identity** every newt-based agent runs
4//! under — the git author/committer name + email a future commit path will
5//! stamp, the optional signing key that links that git identity to the §6
6//! writer fingerprint and the agent-mesh `AgentKey` root, and the optional
7//! GitHub App coordinates a later autonomous-push path will mint tokens from.
8//!
9//! gilamonster-agent and any other newt-based agent **inherit** the
10//! compiled-in [`AgentIdentity::default`] (`newt-agent[bot]`) and override it
11//! by shipping their own `agent-identity.toml`.
12//!
13//! # LOAD-BEARING SECURITY RULE — secrets are references, never values
14//!
15//! This struct holds **no field that carries raw key or token material.**
16//! Every secret is a *reference* the runtime resolves on demand:
17//!
18//! - [`AgentIdentity::signing_key`] / [`AgentIdentity::public_key`] and
19//!   [`GithubApp::private_key`] are filesystem **paths** (tilde-expanded).
20//! - Each [`tokens`](AgentIdentity::tokens) entry is a [`SecretRef`] — an
21//!   `{env|file|cmd}` reference, resolved by [`SecretRef::resolve`].
22//!
23//! That is what makes `agent-identity.toml` safe to commit: it contains only
24//! the agent's public name/email, public app id/client id, and *paths to* the
25//! secrets — never a private key, never a token. The [`AgentIdentity::token`]
26//! and [`SecretRef::resolve`] results are wrapped in [`Secret`], which does not
27//! implement `Serialize`/`Display`/`Debug`-of-the-value, so a resolved secret
28//! cannot be round-tripped back into a config file or logged by accident.
29//!
30//! Resolution precedence mirrors `.newt/config.toml` exactly (see
31//! [`AgentIdentity::resolve`]): workspace `.newt/agent-identity.toml`
32//! (walk-up) > `~/.newt/agent-identity.toml` > `/etc/newt/agent-identity.toml`
33//! > the compiled-in default.
34
35use 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
45// ---------------------------------------------------------------------------
46// Compiled-in defaults — the inherited `newt-agent[bot]` base
47// ---------------------------------------------------------------------------
48
49/// The default agent name newt commits under when no config overrides it.
50pub const DEFAULT_AGENT_NAME: &str = "newt-agent[bot]";
51/// The default agent email — the GitHub `[bot]` noreply address.
52pub const DEFAULT_AGENT_EMAIL: &str = "293447090+newt-agent[bot]@users.noreply.github.com";
53
54// ---------------------------------------------------------------------------
55// Secret-by-reference primitives
56// ---------------------------------------------------------------------------
57
58/// A resolved secret value, wrapped so it cannot be serialized, formatted, or
59/// logged by accident.
60///
61/// Deliberately *not* `Serialize`/`Deserialize` (a resolved secret must never
62/// round-trip back into a config file) and its `Debug` redacts the value. Call
63/// [`Secret::expose`] at the single point of use that genuinely needs the
64/// bytes (e.g. an `Authorization:` header) and never store the result.
65#[derive(Clone, PartialEq, Eq)]
66pub struct Secret(String);
67
68impl Secret {
69    /// Wrap a freshly-resolved secret string.
70    #[must_use]
71    pub fn new(value: impl Into<String>) -> Self {
72        Self(value.into())
73    }
74
75    /// Borrow the underlying secret value. Use only at the point of
76    /// consumption; never log or persist the result.
77    #[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        // Never print the value — not even in panic/Debug output.
86        f.write_str("Secret(<redacted>)")
87    }
88}
89
90/// A reference to a secret — never the secret itself.
91///
92/// Exactly one of the three sources is set per entry:
93///
94/// ```toml
95/// [agent-identity.tokens]
96/// from_env  = { env  = "SOME_TOKEN" }                 # read $SOME_TOKEN
97/// from_file = { file = "~/.secrets/tok" }             # first non-empty line
98/// from_cmd  = { cmd  = "pass show ci/token" }         # stdout of the command
99/// ```
100///
101/// This is the same secret-by-reference shape as
102/// [`crate::config::BackendConfig::resolve_api_key`] (env-or-file), extended
103/// with a `cmd` source for secret managers (`pass`, `vault`, `op`, …).
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
105#[serde(default, deny_unknown_fields)]
106pub struct SecretRef {
107    /// Environment variable to read the secret from.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub env: Option<String>,
110    /// File whose first non-empty line is the secret (tilde-expanded).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub file: Option<String>,
113    /// Shell command whose trimmed stdout is the secret.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub cmd: Option<String>,
116}
117
118impl SecretRef {
119    /// Resolve this reference to its [`Secret`] value.
120    ///
121    /// Source precedence when more than one is set: `env` → `file` → `cmd`.
122    /// Returns `Ok(None)` when the configured source resolves to nothing
123    /// (missing env var / empty file) and `Err` only when a configured `cmd`
124    /// fails to execute or exits non-zero — a misconfiguration the caller
125    /// should see rather than silently treat as "no token".
126    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// ---------------------------------------------------------------------------
178// GitHub App config (for a later autonomous-push path)
179// ---------------------------------------------------------------------------
180
181/// GitHub App coordinates for a *future* autonomous-push path. Surfaced here as
182/// config only — token MINTING is deliberately out of scope.
183///
184/// `app_id` / `client_id` / `installation_id` are public identifiers; the only
185/// secret is `private_key`, which is a filesystem **path** (tilde-expanded),
186/// never inline PEM.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct GithubApp {
189    /// The GitHub App's numeric id (public).
190    pub app_id: u64,
191    /// The GitHub App's client id, e.g. `Iv23li...` (public).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub client_id: Option<String>,
194    /// The installation id this agent acts as (public).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub installation_id: Option<u64>,
197    /// Filesystem **path** to the App's PEM private key (tilde-expanded).
198    /// Never inline PEM. `None` when the key is provisioned out-of-band.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub private_key: Option<String>,
201}
202
203// ---------------------------------------------------------------------------
204// The identity config itself
205// ---------------------------------------------------------------------------
206
207/// A newt-based agent's commit identity.
208///
209/// Resolved with the same precedence as `.newt/config.toml`. See the module
210/// docs for the secrets-by-reference rule: this struct carries **no raw key or
211/// token material**, only the public name/email and *paths to* / *references
212/// for* secrets.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename = "agent-identity")]
215pub struct AgentIdentity {
216    /// Git author/committer name (public). Default: `newt-agent[bot]`.
217    pub name: String,
218    /// Git author/committer email (public). Default: the `[bot]` noreply
219    /// address for `newt-agent[bot]`.
220    pub email: String,
221
222    /// Filesystem **path** to the agent's signing key PEM (tilde-expanded),
223    /// the [`agent_mesh_protocol::UserKey`] that roots the §6 writer
224    /// fingerprint and the mesh `AgentKey`. A **path**, never inline key
225    /// material. `None` → no commit signing / no mesh-rooted identity.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub signing_key: Option<String>,
228
229    /// Filesystem **path** to the matching public key (tilde-expanded).
230    /// A **path**, never inline key material. Optional.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub public_key: Option<String>,
233
234    /// GitHub App coordinates for a later autonomous-push path. `None` by
235    /// default — newt never mints a token unless this is set (and even then,
236    /// minting is out of scope for the foundation).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub github_app: Option<GithubApp>,
239
240    /// Named token references (`[agent-identity.tokens]`). Each value is a
241    /// [`SecretRef`] (`{env|file|cmd}`) — the NAME is config, the VALUE is
242    /// resolved on demand and never stored here. Empty by default.
243    #[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    /// The compiled-in `newt-agent[bot]` base every newt agent inherits.
249    /// No signing key, no GitHub App, no tokens — a fresh newt with zero
250    /// config resolves cleanly to this.
251    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/// Which layer an [`AgentIdentity`] resolved from. Surfaced by
264/// [`AgentIdentity::resolve_with_source`] so `newt identity` can tell the user
265/// *where* the identity came from.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum IdentitySource {
268    /// A project-local `.newt/agent-identity.toml` (walk-up from cwd).
269    Workspace(PathBuf),
270    /// `~/.newt/agent-identity.toml`.
271    Home(PathBuf),
272    /// `/etc/newt/agent-identity.toml`.
273    System(PathBuf),
274    /// The compiled-in `newt-agent[bot]` default — no file existed.
275    Default,
276}
277
278impl IdentitySource {
279    /// A short human label for `newt identity` output.
280    #[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    /// Load an identity config from an explicit file path.
293    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    /// Parse an identity config from a TOML string.
299    ///
300    /// The file's top-level table is `[agent-identity]`, so we read that
301    /// sub-table and deserialize it over the compiled-in default (any omitted
302    /// field inherits the `newt-agent[bot]` base).
303    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    /// Deserialize from a raw `toml::Value` whose `[agent-identity]` sub-table
310    /// (if present) is layered over [`AgentIdentity::default`].
311    fn from_value(value: toml::Value) -> Result<Self> {
312        // Start from the compiled-in default, then overlay the file's
313        // `[agent-identity]` table so partial files inherit name/email/etc.
314        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    /// Resolve the agent identity using the same precedence as
324    /// `.newt/config.toml`:
325    ///
326    /// 1. workspace `.newt/agent-identity.toml` (walk-up from cwd, stopping
327    ///    before `$HOME`),
328    /// 2. `~/.newt/agent-identity.toml`,
329    /// 3. `/etc/newt/agent-identity.toml`,
330    /// 4. the compiled-in [`AgentIdentity::default`] (`newt-agent[bot]`).
331    ///
332    /// First file found wins; no file → the default. This never requires any
333    /// file to exist.
334    pub fn resolve() -> Result<Self> {
335        Ok(Self::resolve_with_source()?.0)
336    }
337
338    /// Like [`AgentIdentity::resolve`] but also reports which layer it came
339    /// from (for `newt identity`).
340    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    /// The resolution core, parameterized on cwd + home so it is unit-testable
348    /// against temp dirs without mutating the process environment.
349    #[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        // 1. Workspace walk-up — reuse the exact config.toml walk-up logic.
364        if let Some(start) = cwd {
365            if let Some(cfg) = find_project_config_from(start, home) {
366                // find_project_config_from returns `.../.newt/config.toml`;
367                // the identity file is its sibling.
368                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            // The walk-up above only matches dirs that already host a
377            // `config.toml`. Also honor a standalone `.newt/agent-identity.toml`
378            // with no sibling config (an agent may ship identity alone).
379            if let Some(found) = find_identity_walkup(start, home) {
380                return Ok((Self::load(&found)?, IdentitySource::Workspace(found)));
381            }
382        }
383
384        // 2. Home.
385        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        // 3. System.
393        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        // 4. Compiled-in default.
399        Ok((Self::default(), IdentitySource::Default))
400    }
401
402    // -----------------------------------------------------------------------
403    // Accessors — the API a future commit path consumes
404    // -----------------------------------------------------------------------
405
406    /// `(name, email)` for the git author/committer.
407    #[must_use]
408    pub fn git_author(&self) -> (String, String) {
409        (self.name.clone(), self.email.clone())
410    }
411
412    /// A `Co-Authored-By:` trailer line for commit messages.
413    #[must_use]
414    pub fn co_author_trailer(&self) -> String {
415        format!("Co-Authored-By: {} <{}>", self.name, self.email)
416    }
417
418    /// The configured signing-key path, tilde-expanded. `None` when unset
419    /// (the default identity mints nothing).
420    #[must_use]
421    pub fn signing_key_path(&self) -> Option<PathBuf> {
422        self.signing_key.as_deref().map(expand_tilde)
423    }
424
425    /// The configured public-key path, tilde-expanded. `None` when unset.
426    #[must_use]
427    pub fn public_key_path(&self) -> Option<PathBuf> {
428        self.public_key.as_deref().map(expand_tilde)
429    }
430
431    /// Resolve a named token reference to its [`Secret`]. `Ok(None)` when no
432    /// token of that name is configured (or its source is empty); `Err` only
433    /// when a configured `cmd` token fails. The value is never stored on the
434    /// struct.
435    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    /// The GitHub App config, if any. Token-minting is deferred; this only
443    /// surfaces the (public) coordinates + the private-key *path*.
444    #[must_use]
445    pub fn github_app(&self) -> Option<&GithubApp> {
446        self.github_app.as_ref()
447    }
448}
449
450/// Walk up from `start` (stopping before `home` and at the filesystem root)
451/// looking for a standalone `.newt/agent-identity.toml`. Innermost wins.
452fn 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// ---------------------------------------------------------------------------
468// Tests
469// ---------------------------------------------------------------------------
470
471#[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        // cwd is an empty temp dir, home a different empty temp dir: nothing on
504        // disk → the compiled-in newt-agent[bot] default, cleanly.
505        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        // With only home set, home wins over the default.
525        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        // Now add a workspace file: workspace wins over home.
532        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        // A file that sets only `name` must inherit the default email.
553        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        // The default identity has no key paths.
594        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        // SAFETY: single-threaded test; the var name is unique to this test.
610        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        // Missing env → Ok(None), not an error.
615        assert!(id.token("svc").unwrap().is_none());
616        // Unknown token name → Ok(None).
617        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        // A TOML *literal* string ('...') — not a basic ("...") string — because
626        // a temp path is backslash-laden on Windows and `\U`/`\A`/etc. are
627        // invalid escapes in a basic string (the file content here is fine on
628        // any platform; only the embedded path needs the literal quoting).
629        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        // The private key is a PATH, never inline material.
698        assert_eq!(
699            app.private_key.as_deref(),
700            Some("~/.vault-secrets/agents/x/app.pem")
701        );
702        // Default identity has no app.
703        assert!(AgentIdentity::default().github_app().is_none());
704    }
705
706    #[test]
707    fn round_trips_through_toml_with_no_raw_secret_field() {
708        // The serialized form contains only public material: name, email,
709        // paths, app ids, and the token-reference *sources* — never a resolved
710        // secret value.
711        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        // Literal string ('...') for the embedded path — backslash-safe on
715        // Windows (see `token_resolves_from_file` for the rationale).
716        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        // Serialize the way the file is shaped: a top-level `[agent-identity]`
733        // section. A single-key map gives us that nesting for free.
734        let mut section = std::collections::BTreeMap::new();
735        section.insert("agent-identity".to_string(), id.clone());
736        let text = toml::to_string_pretty(&section).unwrap();
737
738        // The resolved secret value must never appear in the serialized form.
739        assert!(!text.contains("should-not-appear-in-toml"));
740        // But the path reference is fine to serialize.
741        assert!(text.contains("file"));
742
743        // It must round-trip back to an equal struct.
744        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        // A workspace that ships ONLY agent-identity.toml (no config.toml)
778        // still resolves via the standalone walk-up.
779        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}