From 6a5d9ec3b242316a9aabc8578b293622eda9edac Mon Sep 17 00:00:00 2001 From: yoshiyuki Date: Tue, 2 Jun 2026 02:33:03 +0800 Subject: [PATCH 1/2] feat(cli): make init idempotent --- crates/agentkeys-cli/src/lib.rs | 32 +++++++++++++ crates/agentkeys-cli/src/main.rs | 17 +++++-- crates/agentkeys-cli/tests/cli_tests.rs | 61 ++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 10b9c4fb..125dfd25 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -521,6 +521,23 @@ 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 {}", existing.wallet.0); + return Ok((msg, existing)); + } + } + } + match mode { InitMode::ImportLegacyMock(token) => init_legacy_mock(ctx, token).await, InitMode::Email { @@ -558,6 +575,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 a9d670bf..5b18815f 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 6f6f942f..5f9d148b 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,63 @@ 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 {}", 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() { From 63b492cc98c0032a20d6ad91112895fc98cdef43 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Tue, 2 Jun 2026 09:50:02 +0800 Subject: [PATCH 2/2] fix(cli): name --force in the "Already initialized" message so re-init is discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idempotent init path printed only "Already initialized as ", giving an operator no on-screen hint for how to deliberately re-initialize. Per the project's no-remembered-flags preference (auto-detect over flags; never make the operator recall a flag), surface the override at the exact moment it's relevant: Already initialized as 0x... -> Already initialized as 0x.... Run 'agentkeys init --force' to re-initialize. The default path stays flagless and idempotent; --force remains the single explicit, now self-advertising, escape hatch (its intent — discard a still -valid session — is not derivable from state, so it cannot be auto-detected). Updates the idempotent-init test assertion to match. Refs #3 --- crates/agentkeys-cli/src/lib.rs | 5 ++++- crates/agentkeys-cli/tests/cli_tests.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 125dfd25..5338543c 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -532,7 +532,10 @@ pub async fn cmd_init_with_force( if !force { if let Ok(existing) = ctx.load_session() { if is_usable_session(&existing) { - let msg = format!("Already initialized as {}", existing.wallet.0); + let msg = format!( + "Already initialized as {}. Run 'agentkeys init --force' to re-initialize.", + existing.wallet.0 + ); return Ok((msg, existing)); } } diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index 5f9d148b..b56db7d9 100644 --- a/crates/agentkeys-cli/tests/cli_tests.rs +++ b/crates/agentkeys-cli/tests/cli_tests.rs @@ -105,7 +105,10 @@ async fn init_is_idempotent_when_session_exists() { assert_eq!( second_output, - format!("Already initialized as {}", first_session.wallet.0) + format!( + "Already initialized as {}. Run 'agentkeys init --force' to re-initialize.", + first_session.wallet.0 + ) ); assert_eq!(second_session, first_session); }