diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 10b9c4f..5338543 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -521,6 +521,26 @@ pub enum InitMode { } pub async fn cmd_init(ctx: &CommandContext, mode: InitMode) -> Result<(String, Session)> { + cmd_init_with_force(ctx, mode, false).await +} + +pub async fn cmd_init_with_force( + ctx: &CommandContext, + mode: InitMode, + force: bool, +) -> Result<(String, Session)> { + if !force { + if let Ok(existing) = ctx.load_session() { + if is_usable_session(&existing) { + let msg = format!( + "Already initialized as {}. Run 'agentkeys init --force' to re-initialize.", + existing.wallet.0 + ); + return Ok((msg, existing)); + } + } + } + match mode { InitMode::ImportLegacyMock(token) => init_legacy_mock(ctx, token).await, InitMode::Email { @@ -558,6 +578,21 @@ pub async fn cmd_init(ctx: &CommandContext, mode: InitMode) -> Result<(String, S } } +fn is_usable_session(session: &Session) -> bool { + if session.token.is_empty() || session.wallet.0.is_empty() || session.ttl_seconds == 0 { + return false; + } + + if session.created_at == 0 { + return true; + } + + let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else { + return true; + }; + now.as_secs() <= session.created_at.saturating_add(session.ttl_seconds) +} + /// Test-only: legacy `/session/create` path. Production cannot reach this /// (CLI surface drops `--mock-token`). async fn init_legacy_mock(ctx: &CommandContext, token: String) -> Result<(String, Session)> { diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index a9d670b..5b18815 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -1,8 +1,8 @@ use agentkeys_cli::{ - cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_provision, - cmd_read, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive, cmd_signer_preview_7730, - cmd_signer_sign, cmd_signer_sign_typed_data, cmd_store, cmd_teardown, cmd_whoami, - CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode, + cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init_with_force, + cmd_provision, cmd_read, cmd_revoke, cmd_run, cmd_scope, cmd_signer_derive, + cmd_signer_preview_7730, cmd_signer_sign, cmd_signer_sign_typed_data, cmd_store, cmd_teardown, + cmd_whoami, CommandContext, CredentialBackendKind, EnvelopeVersionFlag, InitMode, }; use clap::{Parser, Subcommand}; @@ -120,6 +120,10 @@ enum Commands { /// click or OAuth2 callback before failing the init. #[arg(long, default_value_t = 300)] poll_timeout_seconds: u64, + + /// Re-run initialization even when a usable local session already exists. + #[arg(long)] + force: bool, }, #[command( @@ -963,6 +967,7 @@ async fn main() { signer_url, chain_id, poll_timeout_seconds, + force, } => { let broker_opt = broker_url.clone().or_else(|| ctx.broker_url.clone()); let signer = signer_url @@ -1000,7 +1005,9 @@ async fn main() { )), }; match mode_result { - Ok(mode) => cmd_init(&ctx, mode).await.map(|(msg, _session)| msg), + Ok(mode) => cmd_init_with_force(&ctx, mode, *force) + .await + .map(|(msg, _session)| msg), Err(e) => Err(e), } } diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index 6f6f942..b56db7d 100644 --- a/crates/agentkeys-cli/tests/cli_tests.rs +++ b/crates/agentkeys-cli/tests/cli_tests.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use agentkeys_cli::{ - cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_provision, cmd_read, cmd_revoke, cmd_run, - cmd_scope, cmd_store, cmd_teardown, CommandContext, InitMode, + cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_init_with_force, cmd_provision, cmd_read, + cmd_revoke, cmd_run, cmd_scope, cmd_store, cmd_teardown, CommandContext, InitMode, }; use agentkeys_core::backend::CredentialBackend; use agentkeys_core::session_store::SessionStore; @@ -80,6 +80,66 @@ fn ctx_verbose_with_session( .with_session_store(store) } +#[tokio::test(flavor = "multi_thread")] +async fn init_is_idempotent_when_session_exists() { + let backend = create_test_backend(); + let (store, _tmp) = test_store(); + let ctx = CommandContext::new("unused", false, false) + .with_backend(backend as Arc) + .with_session_store(store); + + let (first_output, first_session) = cmd_init( + &ctx, + InitMode::ImportLegacyMock("idempotent-token-a".to_string()), + ) + .await + .unwrap(); + assert!(first_output.starts_with("Initialized. Wallet: ")); + + let (second_output, second_session) = cmd_init( + &ctx, + InitMode::ImportLegacyMock("idempotent-token-b".to_string()), + ) + .await + .unwrap(); + + assert_eq!( + second_output, + format!( + "Already initialized as {}. Run 'agentkeys init --force' to re-initialize.", + first_session.wallet.0 + ) + ); + assert_eq!(second_session, first_session); +} + +#[tokio::test(flavor = "multi_thread")] +async fn init_force_overrides_existing_session() { + let backend = create_test_backend(); + let (store, _tmp) = test_store(); + let ctx = CommandContext::new("unused", false, false) + .with_backend(backend as Arc) + .with_session_store(store); + + let (_first_output, first_session) = cmd_init( + &ctx, + InitMode::ImportLegacyMock("force-token-a".to_string()), + ) + .await + .unwrap(); + + let (second_output, second_session) = cmd_init_with_force( + &ctx, + InitMode::ImportLegacyMock("force-token-b".to_string()), + true, + ) + .await + .unwrap(); + + assert!(second_output.starts_with("Initialized. Wallet: ")); + assert_ne!(second_session.wallet, first_session.wallet); +} + // Test 1: init creates a session and returns a wallet address #[tokio::test(flavor = "multi_thread")] async fn cli_init_creates_session() {