diff --git a/atomic-agent/src/identity.rs b/atomic-agent/src/identity.rs index 0367c60c..34831dd0 100644 --- a/atomic-agent/src/identity.rs +++ b/atomic-agent/src/identity.rs @@ -494,6 +494,11 @@ fn extract_toml_string_value(line: &str) -> Option { /// * `agent_name` — Agent registry key (e.g., "claude-code") /// * `agent_display_name` — Human-readable name (e.g., "Claude Code") /// * `session_id` — Session identifier for the `+tag` suffix +/// * `agent_identity` — Name of a delegated agent identity to sign as, +/// resolved by the CLI hook handler (env var > global setting > active +/// server profile). `None` falls back to `ATOMIC_AGENT_IDENTITY` and then +/// to the plus-tag path, so an environment with nothing configured +/// behaves exactly as before agent identities existed. /// /// # Returns /// @@ -502,13 +507,18 @@ fn extract_toml_string_value(line: &str) -> Option { /// /// - With identity: `claude+60f5 ` (with public key ref) /// - Without: `Claude Code` (no email) -pub fn build_agent_author(agent_name: &str, agent_display_name: &str, session_id: &str) -> Author { +pub fn build_agent_author( + agent_name: &str, + agent_display_name: &str, + session_id: &str, + agent_identity: Option<&str>, +) -> Author { let options = AgentAuthorOptions { agent_name, agent_display_name, session_id, identity_dir: None, - agent_identity: None, + agent_identity: agent_identity.map(str::to_string), }; resolve_agent_author(&options) } @@ -943,6 +953,7 @@ identity_type = "user" "claude-code", "Claude Code", "60f5cbd2-aa23-40ee-9085-4375dd186ce7", + None, ); // Can't guarantee identity store exists in test env, so just verify @@ -950,6 +961,34 @@ identity_type = "user" assert!(!author.name.is_empty()); } + /// A name that does not resolve must degrade to a usable author, not an + /// error — recording a turn must never fail over identity selection. + #[test] + fn build_agent_author_never_fails_on_an_unresolvable_name() { + let author = build_agent_author("open-code", "OpenCode", "sess1234", Some("no-such-agent")); + assert!(!author.name.is_empty()); + } + + /// The delegated name must reach the resolver: `build_agent_author` is a + /// thin wrapper over `resolve_agent_author`, so the same inputs — with + /// and without a delegated name — must produce identical authors. + #[test] + fn build_agent_author_threads_the_delegated_name() { + for name in [None, Some("fred+opencode")] { + let via_wrapper = build_agent_author("open-code", "OpenCode", "sess1234", name); + let via_resolver = resolve_agent_author(&AgentAuthorOptions { + agent_name: "open-code", + agent_display_name: "OpenCode", + session_id: "sess1234", + identity_dir: None, + agent_identity: name.map(str::to_string), + }); + assert_eq!(via_wrapper.name, via_resolver.name); + assert_eq!(via_wrapper.email, via_resolver.email); + assert_eq!(via_wrapper.identity, via_resolver.identity); + } + } + // extract_toml_string_value #[test] diff --git a/atomic-agent/src/record/mod.rs b/atomic-agent/src/record/mod.rs index 45f01373..df46caaa 100644 --- a/atomic-agent/src/record/mod.rs +++ b/atomic-agent/src/record/mod.rs @@ -90,7 +90,8 @@ use provenance::{ /// - Good prompt: `"Fix the authentication bug in login.rs"` /// - Slash command or no prompt: `"Add src/main.rs, Cargo.toml"` /// -/// The author is the agent identity. +/// The author is the agent identity — the delegated identity selected for +/// this hook invocation when one is in force, else the plus-tag fallback. fn build_turn_header( options: &TurnRecordOptions<'_>, status: &RepositoryStatus, @@ -102,6 +103,7 @@ fn build_turn_header( &options.session.agent_name, &options.session.agent_display_name, &options.session.session_id, + options.agent_identity.as_deref(), ); ChangeHeader::builder() diff --git a/atomic-agent/src/record/options.rs b/atomic-agent/src/record/options.rs index aa61685a..f4a6b11d 100644 --- a/atomic-agent/src/record/options.rs +++ b/atomic-agent/src/record/options.rs @@ -37,6 +37,17 @@ pub struct TurnRecordOptions<'a> { /// /// Used for the change message and the SessionEnvelope's prompt_summary. pub prompt: Option, + + /// Name of the delegated agent identity to sign this turn as, if one + /// is in force. + /// + /// Resolved once by the CLI hook handler (`ATOMIC_AGENT_IDENTITY` env var, + /// else the global `agent_identity` setting, else the active server + /// profile's binding) and passed as plain data — this crate never reads + /// config. `None` means no delegated identity was selected, and the + /// author falls back to the plus-tag of the default identity, exactly + /// as before agent identities existed. + pub agent_identity: Option, } /// The result of recording a turn as an Atomic change. diff --git a/atomic-agent/src/record/provenance.rs b/atomic-agent/src/record/provenance.rs index 59590a09..c1107e2c 100644 --- a/atomic-agent/src/record/provenance.rs +++ b/atomic-agent/src/record/provenance.rs @@ -244,7 +244,9 @@ pub(crate) fn build_turn_envelope( // has no delegated identity — the plus-tag path signs with the human's key // and there is no certificate to point at, which is precisely the // difference the field is there to record. - if let Some(urn) = crate::identity::active_delegation_urn(None, None) { + if let Some(urn) = + crate::identity::active_delegation_urn(options.agent_identity.as_deref(), None) + { builder = builder.delegation_id(urn); } diff --git a/atomic-agent/src/record/tests.rs b/atomic-agent/src/record/tests.rs index fe58cca1..3ed699bd 100644 --- a/atomic-agent/src/record/tests.rs +++ b/atomic-agent/src/record/tests.rs @@ -35,6 +35,7 @@ fn make_options<'a>(session: &'a AgentSession, event: &'a TurnEvent) -> TurnReco turn_number: 3, turn_duration_ms: 12400, prompt: Some("Fix the authentication bug in login.rs".to_string()), + agent_identity: None, } } @@ -869,6 +870,7 @@ fn test_record_turn_nonexistent_repo_fails() { turn_number: 3, turn_duration_ms: 5000, prompt: Some("Fix the bug".to_string()), + agent_identity: None, }; let result = record_turn(Path::new("/nonexistent/repo/path"), &options); @@ -1109,6 +1111,7 @@ fn test_orphaned_session_view_duplicates_content_on_merge() { turn_number: 1, turn_duration_ms: 1000, prompt: Some("Bump step10".to_string()), + agent_identity: None, }; record_turn(repo_root, &options_a).unwrap(); @@ -1134,6 +1137,7 @@ fn test_orphaned_session_view_duplicates_content_on_merge() { turn_number: 1, turn_duration_ms: 1000, prompt: Some("Bump step70".to_string()), + agent_identity: None, }; record_turn(repo_root, &options_b) .expect("record_turn should self-heal an orphaned session view rather than fail"); diff --git a/atomic-agent/src/turn/orchestrator/mod.rs b/atomic-agent/src/turn/orchestrator/mod.rs index 90bf19e6..1cdf88c5 100644 --- a/atomic-agent/src/turn/orchestrator/mod.rs +++ b/atomic-agent/src/turn/orchestrator/mod.rs @@ -394,6 +394,15 @@ pub struct TurnOrchestrator { /// Human-readable agent display name (e.g., "Claude Code"). pub(crate) agent_display_name: String, + /// Delegated agent identity to sign recorded turns as, if one is in + /// force for this hook invocation. + /// + /// Resolved by the CLI hook handler from the env var / global setting / + /// active server profile chain, and passed as plain data — the + /// orchestrator never reads config itself. `None` keeps the plus-tag + /// fallback, which is the behavior from before agent identities existed. + pub(crate) agent_identity: Option, + /// Managed-run context when a governing lifecycle covers this hook; /// `None` for direct agent usage (behavior unchanged). pub(crate) managed_run: Option, @@ -437,6 +446,7 @@ impl TurnOrchestrator { watcher, agent_name: "unknown".to_string(), agent_display_name: "Unknown Agent".to_string(), + agent_identity: None, managed_run: None, journal_sink: None, }) @@ -457,6 +467,7 @@ impl TurnOrchestrator { watcher, agent_name: "unknown".to_string(), agent_display_name: "Unknown Agent".to_string(), + agent_identity: None, managed_run: None, journal_sink: None, } @@ -472,6 +483,18 @@ impl TurnOrchestrator { self.agent_display_name = display_name.into(); } + /// Set the delegated agent identity that recorded turns sign as. + /// + /// Called by the CLI hook handler after construction with the name + /// resolved from the selection chain (`ATOMIC_AGENT_IDENTITY` env var, + /// else the global `agent_identity` setting, else the active server + /// profile's binding). `None` — the default — keeps the plus-tag + /// fallback. The name crosses as plain data; resolution errors were + /// already handled upstream and degrade to `None`. + pub fn set_agent_identity(&mut self, identity: Option) { + self.agent_identity = identity; + } + /// Attach the managed-run context for this hook invocation /// (see [`ManagedRunContext`]). pub fn set_managed_run(&mut self, context: ManagedRunContext) { diff --git a/atomic-agent/src/turn/orchestrator/session_end.rs b/atomic-agent/src/turn/orchestrator/session_end.rs index 9ae32b89..6b2adb34 100644 --- a/atomic-agent/src/turn/orchestrator/session_end.rs +++ b/atomic-agent/src/turn/orchestrator/session_end.rs @@ -117,6 +117,7 @@ impl TurnOrchestrator { turn_number, turn_duration_ms, prompt, + agent_identity: self.agent_identity.clone(), }; record_turn(&self.repo_root, &record_options) }; diff --git a/atomic-agent/src/turn/orchestrator/turn.rs b/atomic-agent/src/turn/orchestrator/turn.rs index 32045281..d05b5156 100644 --- a/atomic-agent/src/turn/orchestrator/turn.rs +++ b/atomic-agent/src/turn/orchestrator/turn.rs @@ -204,6 +204,7 @@ impl TurnOrchestrator { turn_number: session.turn_count.saturating_add(1), turn_duration_ms: 0, prompt: None, + agent_identity: self.agent_identity.clone(), }; if crate::record::scope::has_pending_changes(&self.repo_root, &options)? { return Err(AgentError::RecordFailed { @@ -328,6 +329,7 @@ impl TurnOrchestrator { turn_number, turn_duration_ms, prompt, + agent_identity: self.agent_identity.clone(), }; match record_turn(&self.repo_root, &record_options) { diff --git a/atomic-agent/tests/record_view_mismatch_regression.rs b/atomic-agent/tests/record_view_mismatch_regression.rs index f83a9129..6eaca5d1 100644 --- a/atomic-agent/tests/record_view_mismatch_regression.rs +++ b/atomic-agent/tests/record_view_mismatch_regression.rs @@ -30,6 +30,7 @@ fn options<'a>( turn_number, turn_duration_ms: 1000, prompt: Some(prompt.to_string()), + agent_identity: None, } } diff --git a/atomic-cli/src/commands/agent/hooks.rs b/atomic-cli/src/commands/agent/hooks.rs index 3a115235..9ebf32a6 100644 --- a/atomic-cli/src/commands/agent/hooks.rs +++ b/atomic-cli/src/commands/agent/hooks.rs @@ -264,6 +264,24 @@ impl Command for Hooks { orchestrator .set_journal_sink(Arc::new(super::owner::OwnerJournalSink::new(&repo_root))); + // The delegated identity to sign recorded turns as, resolved + // through the selection chain (env var > global setting > + // active server profile). `None` keeps the plus-tag fallback, + // so an environment with nothing configured behaves exactly as + // before agent identities existed. + let agent_identity = super::identity::resolve_effective_agent_identity(); + match &agent_identity { + Some((name, source)) => { + log::debug!("hooks: recording as agent identity '{name}' ({source})"); + } + None => { + log::debug!( + "hooks: no agent identity selected; recording as plus-tag of default" + ); + } + } + orchestrator.set_agent_identity(agent_identity.map(|(name, _)| name)); + // Under a managed lifecycle, sessions adopt the declared view // and carry the run stamp (see lifecycle module docs). if let Some(lc) = &managed { diff --git a/atomic-cli/src/commands/agent/identity.rs b/atomic-cli/src/commands/agent/identity.rs new file mode 100644 index 00000000..488f648c --- /dev/null +++ b/atomic-cli/src/commands/agent/identity.rs @@ -0,0 +1,454 @@ +//! `atomic agent identity` — select the delegated identity hooks sign as. +//! +//! Agent hooks record turns as the plus-tag of the default identity by +//! default — legible, but keyed to the human. This command tree selects a +//! delegated agent identity so every hooked agent on this machine records +//! under the agent's own key instead. +//! +//! The selection is deliberately **global** (one identity per machine, not +//! per repository): `set` writes `agent_identity` to `~/.atomic/config.toml`, +//! and every hook invocation — in any repo, for any agent — resolves it +//! fresh. `ATOMIC_AGENT_IDENTITY` remains the per-process escape hatch and +//! takes precedence, and the active server profile's `agent_identity` +//! binding (written by `atomic identity agent create`) is the implicit +//! fallback when nothing explicit is set. +//! +//! Resolution order (shared with the hook handler, see +//! [`resolve_effective_agent_identity`]): +//! +//! ```text +//! ATOMIC_AGENT_IDENTITY env var +//! > global agent_identity setting +//! > active server profile's agent_identity +//! > plus-tag fallback (behavior from before agent identities existed) +//! ``` +//! +//! # Examples +//! +//! ```text +//! # Select an agent identity (machine-wide) +//! atomic agent identity set fred+opencode +//! +//! # Stop recording as the agent +//! atomic agent identity unset +//! +//! # See what hooks will sign as, and why +//! atomic agent identity show +//! ``` + +use clap::{Args, Subcommand}; + +use atomic_canonical::delegation as cert; +use atomic_identity::IdentityStore; + +use atomic_config::GlobalConfig; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +// Identity Command + +/// Select or inspect the agent identity that recording hooks sign as. +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +pub struct Identity { + #[command(subcommand)] + command: IdentityCommands, +} + +/// Available `atomic agent identity` subcommands. +#[derive(Debug, Subcommand)] +pub enum IdentityCommands { + /// Select the agent identity for hook recording (machine-wide). + /// + /// Validates the identity eagerly — it must exist in the identity store + /// and be an agent/delegated identity, since a human identity here + /// would silently sign agent work with the human's key — then writes it + /// to `~/.atomic/config.toml` as the global `agent_identity`. Every + /// hooked agent on this machine records under it from the next hook + /// invocation on. + /// + /// # Examples + /// + /// ```text + /// atomic agent identity set fred+opencode + /// ``` + Set(Set), + + /// Clear the agent identity selection. + /// + /// Hooks go back to recording as the plus-tag of the default identity — + /// the behavior from before agent identities existed. + /// + /// # Examples + /// + /// ```text + /// atomic agent identity unset + /// ``` + Unset(Unset), + + /// Show the effective agent identity and where it comes from. + /// + /// Runs the same resolution the hooks use (env var, global setting, + /// active server profile, or none), so what you see here is exactly + /// what the next recorded turn will sign as. + /// + /// # Examples + /// + /// ```text + /// atomic agent identity show + /// ``` + Show(Show), +} + +impl Command for Identity { + fn run(&self) -> CliResult<()> { + match &self.command { + IdentityCommands::Set(cmd) => cmd.run(), + IdentityCommands::Unset(cmd) => cmd.run(), + IdentityCommands::Show(cmd) => cmd.run(), + } + } +} + +// Effective Identity Resolution + +/// Where a resolved agent identity came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentIdentitySource { + /// The `ATOMIC_AGENT_IDENTITY` environment variable. + EnvVar, + /// The global `agent_identity` setting in `~/.atomic/config.toml`. + GlobalSetting, + /// The active server profile's `agent_identity` binding. + ServerProfile, +} + +impl std::fmt::Display for AgentIdentitySource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + AgentIdentitySource::EnvVar => "ATOMIC_AGENT_IDENTITY env var", + AgentIdentitySource::GlobalSetting => "global setting (~/.atomic/config.toml)", + AgentIdentitySource::ServerProfile => "active server profile", + }) + } +} + +impl AgentIdentitySource { + /// Stable machine key for JSON output (`env`, `global`, `server-profile`). + /// The human labels live in the `Display` impl. + pub fn key(&self) -> &'static str { + match self { + AgentIdentitySource::EnvVar => "env", + AgentIdentitySource::GlobalSetting => "global", + AgentIdentitySource::ServerProfile => "server-profile", + } + } +} + +/// Resolve the agent identity that recording hooks should sign as. +/// +/// Order: `ATOMIC_AGENT_IDENTITY` env var, else the global `agent_identity` +/// setting, else the active server profile's `agent_identity` binding. +/// Returns `None` when nothing is configured — hooks then record as the +/// plus-tag of the default identity, exactly as before agent identities +/// existed. Config read failures degrade to `None`: a hook must never fail +/// over identity selection. +pub fn resolve_effective_agent_identity() -> Option<(String, AgentIdentitySource)> { + // 1. Env var — the per-process escape hatch, and the only handle a + // remote runner has. + let env = std::env::var(atomic_agent::identity::AGENT_IDENTITY_ENV) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + + // An unreadable config degrades to no selection rather than failing + // the hook. + let config = GlobalConfig::load().ok()?; + resolve_from_parts(env.as_deref(), &config) +} + +/// Pure resolution over the chain, split from the I/O so the order is +/// unit-testable. +/// +/// Env var beats the global setting beats the active server profile; empty +/// or whitespace-only values are skipped as if unset at every level. +fn resolve_from_parts( + env: Option<&str>, + config: &GlobalConfig, +) -> Option<(String, AgentIdentitySource)> { + if let Some(name) = env.map(str::trim).filter(|n| !n.is_empty()) { + return Some((name.to_string(), AgentIdentitySource::EnvVar)); + } + if let Some(name) = config.agent_identity.as_deref().map(str::trim) { + if !name.is_empty() { + return Some((name.to_string(), AgentIdentitySource::GlobalSetting)); + } + } + if let Some(name) = config.active_server_agent_identity().map(str::trim) { + if !name.is_empty() { + return Some((name.to_string(), AgentIdentitySource::ServerProfile)); + } + } + None +} + +// Set Command + +/// Select the agent identity for hook recording. +#[derive(Debug, Args)] +pub struct Set { + /// Name of the agent identity to sign as (e.g., "fred+opencode"). + name: String, +} + +impl Command for Set { + fn run(&self) -> CliResult<()> { + let name = self.name.trim(); + if name.is_empty() { + return Err(CliError::InvalidArgument { + message: "Identity name cannot be empty".to_string(), + }); + } + + // Eager validation: the record path falls back softly on a bad + // name, so this is the one place with room to say what's wrong. + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + let identity = store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.to_string()))?; + + // A human identity here would silently sign agent work as the + // human with no delegation behind it — refuse it outright. + if !identity.identity_type.is_delegated() && !identity.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{name}' is a human identity. Agent hooks can only sign as an agent \ + identity — create one with `atomic identity agent create`." + ), + }); + } + + // Keyed attribution without a certificate still records (the key is + // the agent's), but the envelope will carry no delegation URN and a + // server cannot confirm the delegation. That is worth saying now, + // not discovering from a change header later. + if cert::active_for_delegate(&store, &identity).is_none() { + print_warning(&format!( + "No active delegation certificate for '{name}'. Turns will record keyed to \ + the agent, but the envelope will carry no delegation URN until a \ + certificate is granted (`atomic identity grant new {name}`)." + )); + } + + let mut config = GlobalConfig::load().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load global config: {e}")) + })?; + config.agent_identity = Some(name.to_string()); + config.save().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to save global config: {e}")) + })?; + + print_success(&format!( + "Agent identity set to '{name}' — recording hooks now sign as it" + )); + print_hint("Effective immediately for every hooked agent on this machine"); + Ok(()) + } +} + +// Unset Command + +/// Clear the agent identity selection. +#[derive(Debug, Args)] +pub struct Unset {} + +impl Command for Unset { + fn run(&self) -> CliResult<()> { + let mut config = GlobalConfig::load().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load global config: {e}")) + })?; + let had = config.agent_identity.is_some(); + config.agent_identity = None; + config.save().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to save global config: {e}")) + })?; + + if had { + print_success( + "Agent identity unset — hooks record as the plus-tag of the default identity", + ); + } else { + print_hint("No agent identity was set"); + } + Ok(()) + } +} + +// Show Command + +/// Show the effective agent identity and where it comes from. +#[derive(Debug, Args)] +pub struct Show {} + +impl Command for Show { + fn run(&self) -> CliResult<()> { + match resolve_effective_agent_identity() { + Some((name, source)) => { + print_success(&format!("Agent identity: {name}")); + println!(" Source: {source}"); + // The record path falls back softly when the name does not + // resolve — show should say so, because that is a silent + // difference between what is selected and what records. + match IdentityStore::open_default() { + Ok(store) => match store.load_by_name(&name) { + Ok(identity) => { + println!(" Email: {}", identity.email.as_deref().unwrap_or("-")); + println!(" Key: {}", identity.public_key_base32()); + match cert::active_for_delegate(&store, &identity) { + Some(d) => { + println!(" Delegation: {}", d.delegation.id.to_urn()); + } + None => { + print_warning( + "No active delegation certificate — turns record \ + keyed to the agent but carry no delegation URN", + ); + } + } + } + Err(_) => { + print_warning(&format!( + "'{name}' does not resolve in the identity store — \ + hooks will fall back to the plus-tag path" + )); + } + }, + Err(e) => { + print_warning(&format!( + "Could not open identity store ({e}) — hooks will fall \ + back to the plus-tag path if the identity stays unresolvable" + )); + } + } + } + None => { + print_hint( + "No agent identity selected — recording hooks sign as the \ + plus-tag of the default identity", + ); + print_hint("Select one with: atomic agent identity set "); + } + } + Ok(()) + } +} + +// Tests + +#[cfg(test)] +mod tests { + use super::*; + + // The env var and the real ~/.atomic/config.toml both sit outside this + // test's control, so the chain is pinned against synthetic parts via + // resolve_from_parts — the same function the hook handler reaches + // through resolve_effective_agent_identity. + #[test] + fn env_var_beats_everything() { + let config = GlobalConfig { + agent_identity: Some("global+agent".to_string()), + server: atomic_config::ServerConfig { + agent_identity: Some("server+agent".to_string()), + ..atomic_config::ServerConfig::default() + }, + ..GlobalConfig::default() + }; + let (name, source) = + resolve_from_parts(Some("env+agent"), &config).expect("env var must win"); + assert_eq!(name, "env+agent"); + assert_eq!(source, AgentIdentitySource::EnvVar); + } + + #[test] + fn global_setting_beats_server_profile() { + let config = GlobalConfig { + agent_identity: Some("global+agent".to_string()), + server: atomic_config::ServerConfig { + agent_identity: Some("server+agent".to_string()), + ..atomic_config::ServerConfig::default() + }, + ..GlobalConfig::default() + }; + let (name, source) = + resolve_from_parts(None, &config).expect("global setting must beat profile"); + assert_eq!(name, "global+agent"); + assert_eq!(source, AgentIdentitySource::GlobalSetting); + } + + #[test] + fn server_profile_is_the_implicit_fallback() { + let config = GlobalConfig { + server: atomic_config::ServerConfig { + agent_identity: Some("server+agent".to_string()), + ..atomic_config::ServerConfig::default() + }, + ..GlobalConfig::default() + }; + let (name, source) = + resolve_from_parts(None, &config).expect("profile binding must be used"); + assert_eq!(name, "server+agent"); + assert_eq!(source, AgentIdentitySource::ServerProfile); + } + + #[test] + fn nothing_set_means_no_selection() { + // The backward-compatibility guarantee: with nothing configured, + // there is no selection and hooks keep the plus-tag path. + assert!(resolve_from_parts(None, &GlobalConfig::default()).is_none()); + } + + #[test] + fn blank_values_are_skipped_at_every_level() { + let config = GlobalConfig { + agent_identity: Some(" ".to_string()), + server: atomic_config::ServerConfig { + agent_identity: Some("".to_string()), + ..atomic_config::ServerConfig::default() + }, + ..GlobalConfig::default() + }; + assert!(resolve_from_parts(Some(" "), &config).is_none()); + + // A blank global falls through to the profile binding. + let config = GlobalConfig { + server: atomic_config::ServerConfig { + agent_identity: Some("server+agent".to_string()), + ..atomic_config::ServerConfig::default() + }, + ..GlobalConfig::default() + }; + let (name, source) = resolve_from_parts(None, &config).unwrap(); + assert_eq!(name, "server+agent"); + assert_eq!(source, AgentIdentitySource::ServerProfile); + } + + #[test] + fn source_labels_are_stable() { + assert_eq!( + AgentIdentitySource::EnvVar.to_string(), + "ATOMIC_AGENT_IDENTITY env var" + ); + assert_eq!( + AgentIdentitySource::GlobalSetting.to_string(), + "global setting (~/.atomic/config.toml)" + ); + assert_eq!( + AgentIdentitySource::ServerProfile.to_string(), + "active server profile" + ); + } +} diff --git a/atomic-cli/src/commands/agent/mod.rs b/atomic-cli/src/commands/agent/mod.rs index 67fa5202..dc4df1ea 100644 --- a/atomic-cli/src/commands/agent/mod.rs +++ b/atomic-cli/src/commands/agent/mod.rs @@ -42,6 +42,7 @@ mod disable; mod enable; mod explain; mod hooks; +mod identity; mod lifecycle; mod owner; mod status; @@ -55,6 +56,7 @@ pub use attest::Attest; pub use disable::Disable; pub use enable::Enable; pub use explain::Explain; +pub use identity::Identity; pub use lifecycle::Lifecycle; pub use status::AgentStatus; @@ -206,6 +208,29 @@ pub enum AgentCommands { #[command(name = "database-owner", hide = true)] DatabaseOwner(owner::DatabaseOwner), + /// Select or inspect the agent identity that recording hooks sign as. + /// + /// `set` makes every hooked agent on this machine record under a + /// delegated agent identity's own key instead of the plus-tag of the + /// default identity. The selection is global — one identity per + /// machine — with `ATOMIC_AGENT_IDENTITY` as the per-process escape + /// hatch and the active server profile's `agent_identity` binding as + /// the implicit fallback. + /// + /// # Examples + /// + /// ```text + /// # Select an agent identity + /// atomic agent identity set fred+opencode + /// + /// # See what hooks will sign as, and why + /// atomic agent identity show + /// + /// # Back to the plus-tag fallback + /// atomic agent identity unset + /// ``` + Identity(Identity), + /// Internal hook handlers (called by agent hooks). /// /// These commands are invoked by the hooks installed in agent @@ -228,6 +253,7 @@ impl Command for Agent { AgentCommands::Attest(cmd) => cmd.run(), AgentCommands::Lifecycle(cmd) => cmd.run(), AgentCommands::DatabaseOwner(cmd) => cmd.run(), + AgentCommands::Identity(cmd) => cmd.run(), AgentCommands::Hooks(cmd) => cmd.run(), } } diff --git a/atomic-cli/src/commands/agent/status.rs b/atomic-cli/src/commands/agent/status.rs index 4a9a2540..498582b7 100644 --- a/atomic-cli/src/commands/agent/status.rs +++ b/atomic-cli/src/commands/agent/status.rs @@ -32,7 +32,8 @@ use crate::error::CliResult; /// Show agent integration status. /// /// Displays which agents have hooks installed, any active sessions, -/// and the current state of the file watcher. +/// the current state of the file watcher, and the delegated identity +/// recording hooks sign as. #[derive(Debug, Args)] pub struct AgentStatus { /// Show detailed session information. @@ -95,6 +96,12 @@ struct StatusJson { agents: Vec, sessions: Vec, totals: Totals, + /// The delegated identity hooks sign as, when one is selected. Absent + /// when hooks record as the plus-tag of the default identity — the + /// same resolution the hooks themselves run, so what this reports is + /// what the next recorded turn will use. + #[serde(skip_serializing_if = "Option::is_none")] + agent_identity: Option, /// Why the session list is empty, when it is empty for a reason. The human /// output prints this and carries on; dropping it from JSON would turn a /// broken session store into an indistinguishable "no sessions". @@ -102,6 +109,17 @@ struct StatusJson { sessions_error: Option, } +/// The effective agent identity, as data. +#[derive(Debug, Serialize)] +struct AgentIdentityJson { + /// The identity's name in the store, e.g. `fred+opencode`. + name: String, + /// Where the selection came from — one of `env`, `global`, + /// `server-profile`. Stable keys; the human labels live in the + /// `agent identity` command. + source: &'static str, +} + impl AgentStatus { /// Create a default instance for testing. #[cfg(test)] @@ -167,6 +185,12 @@ impl AgentStatus { }) .collect(), totals, + agent_identity: super::identity::resolve_effective_agent_identity().map( + |(name, source)| AgentIdentityJson { + name, + source: source.key(), + }, + ), sessions_error, } } @@ -193,6 +217,19 @@ impl Command for AgentStatus { println!("======================="); println!(); + // Identity — the delegated identity hooks sign as, machine-global, + // shown even when no hooks are installed yet because the selection + // is exactly what a future `atomic agent enable` will record as. + match super::identity::resolve_effective_agent_identity() { + Some((name, source)) => { + println!(" Identity: {name} ({source})"); + } + None => { + println!(" Identity: none — hooks record as the plus-tag of the default identity"); + } + } + println!(); + let installed = registry.installed(&repo_root); let detected = registry.detect(&repo_root); diff --git a/atomic-cli/tests/database_owner_integration_test.rs b/atomic-cli/tests/database_owner_integration_test.rs index 28d61d81..6a4b15c8 100644 --- a/atomic-cli/tests/database_owner_integration_test.rs +++ b/atomic-cli/tests/database_owner_integration_test.rs @@ -12,6 +12,18 @@ use fs2::FileExt; use serde_json::Value; use tempfile::TempDir; +// Every test in this file spawns real `atomic` subprocesses that hold the +// redb lock and burn CPU while they run. On 2-core Windows CI runners the +// default test-threads parallelism lets the process-heavy tests (the +// eight-process session-start hammer, the failpoint owners) starve whichever +// sibling tests happen to overlap with them past their database-wait budgets +// — the failures move between runs (concurrent_session_starts…, +// crashing_second_checkpoint…, owner_death_after_checkpoint…, all in the +// same file), which is the signature of scheduler starvation, not a bug in +// any one test. Serializing the file trades a few minutes of wall time for +// runs that fail only when something is actually broken. +use serial_test::serial; + // Bound child processes so a platform-specific IPC regression produces a // useful failure instead of occupying a CI runner indefinitely. Drain output // concurrently so a full pipe cannot prevent the child from exiting. @@ -198,6 +210,7 @@ fn run_agent_hook(repository: &std::path::Path, agent: &str, verb: &str, payload } #[test] +#[serial] fn concurrent_session_starts_wait_for_writer_and_persist_views_and_lifecycle() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -250,6 +263,7 @@ fn concurrent_session_starts_wait_for_writer_and_persist_views_and_lifecycle() { } #[test] +#[serial] fn read_only_turn_does_not_reuse_its_goal_for_the_next_turn_across_agents() { for (agent, prompt_verb) in [ ("codex", "user-prompt-submit"), @@ -355,6 +369,7 @@ fn wait_for_shutdown(repository: &std::path::Path) { } #[test] +#[serial] fn concurrent_hooks_commit_lossless_envelopes_without_output_or_drops() { const TOOL_EVENTS: usize = 16; @@ -498,6 +513,7 @@ fn concurrent_hooks_commit_lossless_envelopes_without_output_or_drops() { } #[test] +#[serial] fn lifecycle_stop_resume_zombie_abandon_and_lease_expiry() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -726,6 +742,7 @@ fn lifecycle_stop_resume_zombie_abandon_and_lease_expiry() { } #[test] +#[serial] fn owner_death_before_and_after_event_commit_retries_exactly_once() { for failpoint in ["before-envelope-commit", "after-envelope-commit"] { let temp = TempDir::new().unwrap(); @@ -803,6 +820,7 @@ fn batch_owner_rpc(repository: &std::path::Path, request: &Value) -> std::io::Re #[cfg(unix)] #[test] +#[serial] fn owner_batch_crash_retries_the_whole_durable_batch_exactly_once() { for failpoint in ["before-envelope-commit", "after-envelope-commit"] { let temp = TempDir::new().unwrap(); @@ -865,6 +883,7 @@ fn owner_batch_crash_retries_the_whole_durable_batch_exactly_once() { } #[test] +#[serial] fn owner_death_after_checkpoint_prepare_and_bind_recovers_in_hook_process() { for failpoint in ["after-checkpoint-prepare", "after-checkpoint-bind"] { let temp = TempDir::new().unwrap(); @@ -929,6 +948,7 @@ fn owner_death_after_checkpoint_prepare_and_bind_recovers_in_hook_process() { } #[test] +#[serial] fn corrupt_legacy_graph_is_retained_when_migration_cannot_verify_it() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -962,6 +982,7 @@ fn corrupt_legacy_graph_is_retained_when_migration_cannot_verify_it() { } #[test] +#[serial] fn legacy_graph_pending_delta_imports_once_then_json_authority_is_removed() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1083,6 +1104,7 @@ fn legacy_graph_pending_delta_imports_once_then_json_authority_is_removed() { } #[test] +#[serial] fn turn_end_publishes_one_checkpoint_turn_and_advances_head() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1164,6 +1186,7 @@ fn turn_end_publishes_one_checkpoint_turn_and_advances_head() { } #[test] +#[serial] fn concurrent_stops_publish_ordered_ledgers_without_external_serialization() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1295,6 +1318,7 @@ fn concurrent_stops_publish_ordered_ledgers_without_external_serialization() { } #[test] +#[serial] fn stop_publication_timeout_preserves_the_active_turn_for_retry() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1337,6 +1361,7 @@ fn stop_publication_timeout_preserves_the_active_turn_for_retry() { } #[test] +#[serial] fn killed_stop_releases_publication_lock_and_can_be_retried() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1568,6 +1593,7 @@ fn large_checkpoint_fixture( } #[test] +#[serial] fn large_frozen_checkpoint_completes_across_agents() { for agent in ["codex", "claude-code", "opencode"] { large_checkpoint_fixture(agent, 1024, 1024, false); @@ -1575,11 +1601,13 @@ fn large_frozen_checkpoint_completes_across_agents() { } #[test] +#[serial] fn large_envelope_checkpoint_recovers_between_pages() { large_checkpoint_fixture("claude-code", 1, 600 * 1024, true); } #[test] +#[serial] fn failed_frozen_read_resumes_recorded_changes_on_next_stop() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1658,6 +1686,7 @@ fn failed_frozen_read_resumes_recorded_changes_on_next_stop() { } #[test] +#[serial] fn pre_cutover_session_count_gap_publishes_at_next_ledger_ordinal() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1704,6 +1733,7 @@ fn pre_cutover_session_count_gap_publishes_at_next_ledger_ordinal() { } #[test] +#[serial] fn crashing_second_checkpoint_keeps_first_turn_immutable() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1773,6 +1803,7 @@ fn crashing_second_checkpoint_keeps_first_turn_immutable() { } #[test] +#[serial] fn owner_election_commit_reconnect_and_crash_restart() { let temp = TempDir::new().unwrap(); let repository = temp.path().join("repo"); @@ -1834,6 +1865,7 @@ fn owner_election_commit_reconnect_and_crash_restart() { } #[test] +#[serial] fn scoped_concurrent_stops_and_session_end_do_not_record_sibling_files() { let temp = TempDir::new().unwrap(); let root = temp.path().join("repo"); @@ -2025,16 +2057,19 @@ fn check_missing_turn_start(write_files: bool) { } #[test] +#[serial] fn missing_turn_start_recovers_changes_and_provenance_without_duplicate_stops() { check_missing_turn_start(true); } #[test] +#[serial] fn missing_turn_start_recovers_read_only_provenance() { check_missing_turn_start(false); } #[test] +#[serial] fn scoped_stop_without_start_or_journal_fails_visibly_and_can_retry() { let temp = TempDir::new().unwrap(); let root = temp.path().join("repo"); diff --git a/atomic-config/src/lib.rs b/atomic-config/src/lib.rs index b39a3cb2..0b463aa0 100644 --- a/atomic-config/src/lib.rs +++ b/atomic-config/src/lib.rs @@ -240,6 +240,19 @@ pub struct GlobalConfig { /// When `None`, the legacy `[server]` block is used. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_server: Option, + + /// Machine-wide agent identity that recording hooks sign as. + /// + /// Set by `atomic agent identity set `. When hooks record a + /// turn, the change is attributed to this identity's own key instead + /// of falling back to the plus-tag of the default identity. Deliberately + /// global — one agent identity per machine, not per repository — with + /// `ATOMIC_AGENT_IDENTITY` as the per-process escape hatch and the + /// active server profile's `agent_identity` as the implicit fallback. + /// + /// Example: `agent_identity = "fred+opencode"` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_identity: Option, } fn default_channel_name() -> String { @@ -257,6 +270,7 @@ impl Default for GlobalConfig { server: ServerConfig::default(), servers: BTreeMap::new(), default_server: None, + agent_identity: None, } } } @@ -350,6 +364,22 @@ impl GlobalConfig { None => Ok((&mut self.server, None)), } } + + /// The active server profile's `agent_identity` binding, if any. + /// + /// Active-profile resolution mirrors [`resolve_server`](Self::resolve_server) + /// with no override: `default_server` → named profile, else the legacy + /// `[server]` block. Unlike `resolve_server`, a dangling `default_server` + /// degrades to the legacy block rather than erroring — this is a fallback + /// link in the recording hook's identity chain, and recording must never + /// fail over a misconfigured profile name. + pub fn active_server_agent_identity(&self) -> Option<&str> { + let profile = match self.default_server.as_deref() { + Some(name) => self.servers.get(name).unwrap_or(&self.server), + None => &self.server, + }; + profile.agent_identity.as_deref() + } } /// Color output preference @@ -914,6 +944,69 @@ default_org = "alice" ); } + #[test] + fn test_agent_identity_roundtrip() { + let config = GlobalConfig { + agent_identity: Some("fred+opencode".to_string()), + ..GlobalConfig::default() + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + assert!(toml_str.contains("agent_identity = \"fred+opencode\"")); + + let parsed: GlobalConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.agent_identity.as_deref(), Some("fred+opencode")); + } + + #[test] + fn test_agent_identity_absent_in_legacy_configs() { + // Configs written before the field existed must deserialize with + // no selection — hooks fall back to the plus-tag path unchanged. + let legacy = "default_channel = \"main\"\n"; + let parsed: GlobalConfig = toml::from_str(legacy).unwrap(); + assert_eq!(parsed.agent_identity, None); + } + + #[test] + fn test_agent_identity_omitted_when_unset() { + // skip_serializing_if: an unset selection leaves no key behind, + // so hand-edited configs stay clean. + let toml_str = toml::to_string_pretty(&GlobalConfig::default()).unwrap(); + assert!(!toml_str.contains("agent_identity")); + } + + #[test] + fn test_active_server_agent_identity_prefers_default_profile() { + let mut config = GlobalConfig::default(); + config.server.agent_identity = Some("legacy+agent".to_string()); + assert_eq!(config.active_server_agent_identity(), Some("legacy+agent")); + + let named = ServerConfig { + agent_identity: Some("named+agent".to_string()), + ..ServerConfig::default() + }; + config.servers.insert("prod".to_string(), named); + config.default_server = Some("prod".to_string()); + assert_eq!(config.active_server_agent_identity(), Some("named+agent")); + } + + #[test] + fn test_active_server_agent_identity_degrades_on_dangling_default() { + // A default_server pointing at a removed profile must not break + // resolution — the legacy block is the honest active profile then. + let mut config = GlobalConfig::default(); + config.server.agent_identity = Some("legacy+agent".to_string()); + config.default_server = Some("gone".to_string()); + assert_eq!(config.active_server_agent_identity(), Some("legacy+agent")); + } + + #[test] + fn test_active_server_agent_identity_none_when_unbound() { + let mut config = GlobalConfig::default(); + config.server.agent_identity = None; + assert_eq!(config.active_server_agent_identity(), None); + } + #[test] fn global_config_dir_uses_env_override_verbatim() { // With the override set, the directory is used exactly as given.