Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)> {
Expand Down
17 changes: 12 additions & 5 deletions crates/agentkeys-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
}
Expand Down
64 changes: 62 additions & 2 deletions crates/agentkeys-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<dyn CredentialBackend>)
.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<dyn CredentialBackend>)
.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() {
Expand Down
Loading