From 94218583ddaf4395c18be67e7c0d75cf1bbf90d8 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:01:03 -0700 Subject: [PATCH 01/10] test(wallet): add remote flow auth and funding coverage --- crates/tempo-wallet/tests/remote_flows.rs | 345 ++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 crates/tempo-wallet/tests/remote_flows.rs diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs new file mode 100644 index 00000000..e10564a1 --- /dev/null +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -0,0 +1,345 @@ +mod common; + +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use axum::{routing::post, Json, Router}; +use serde_json::json; +use tempo_test::{mock_rpc_response, TestConfigBuilder, MODERATO_DIRECT_KEYS_TOML}; + +use common::test_command; + +#[allow(dead_code)] +struct MockLoginServer { + base_url: String, + poll_count: Arc>, + shutdown_tx: Option>, + _handle: tokio::task::JoinHandle<()>, +} + +impl MockLoginServer { + async fn start_authorized(code: &str) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}:{}", addr.ip(), addr.port()); + let poll_count = Arc::new(Mutex::new(0u32)); + + let device_code = code.to_string(); + let poll_code = code.to_string(); + let poll_state = poll_count.clone(); + + let app = Router::new() + .route( + "/cli-auth/device-code", + post(move || { + let code = device_code.clone(); + async move { Json(json!({ "code": code })) } + }), + ) + .route( + "/cli-auth/poll/{code}", + post( + move |axum::extract::Path(path_code): axum::extract::Path| { + let expected = poll_code.clone(); + let poll_state = poll_state.clone(); + async move { + assert_eq!(path_code, expected, "unexpected poll code"); + let mut count = poll_state.lock().unwrap(); + let response = if *count == 0 { + *count += 1; + json!({ "status": "pending" }) + } else { + *count += 1; + json!({ + "status": "authorized", + "account_address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "key_authorization": null + }) + }; + Json(response) + } + }, + ), + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + Self { + base_url, + poll_count, + shutdown_tx: Some(shutdown_tx), + _handle: handle, + } + } + + fn auth_url(&self) -> String { + format!("{}/cli-auth", self.base_url) + } +} + +impl Drop for MockLoginServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +#[allow(dead_code)] +struct BalanceSequenceRpcServer { + base_url: String, + balances: Arc>>, + last_value: Arc>, + shutdown_tx: Option>, + _handle: tokio::task::JoinHandle<()>, +} + +impl BalanceSequenceRpcServer { + async fn start(raw_balances: Vec<&str>) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}:{}", addr.ip(), addr.port()); + + let balances = Arc::new(Mutex::new( + raw_balances + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + )); + let last_value = Arc::new(Mutex::new(String::from("0"))); + + let shared_balances = balances.clone(); + let shared_last_value = last_value.clone(); + + let app = Router::new().route( + "/", + post(move |Json(body): Json| { + let shared_balances = shared_balances.clone(); + let shared_last_value = shared_last_value.clone(); + async move { + if let Some(batch) = body.as_array() { + let response = serde_json::Value::Array( + batch + .iter() + .map(|req| { + handle_rpc_request(req, &shared_balances, &shared_last_value) + }) + .collect(), + ); + Json(response) + } else { + Json(handle_rpc_request( + &body, + &shared_balances, + &shared_last_value, + )) + } + } + }), + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + Self { + base_url, + balances, + last_value, + shutdown_tx: Some(shutdown_tx), + _handle: handle, + } + } +} + +impl Drop for BalanceSequenceRpcServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +fn handle_rpc_request( + req: &serde_json::Value, + balances: &Arc>>, + last_value: &Arc>, +) -> serde_json::Value { + if req["method"].as_str() == Some("eth_call") { + let raw = next_balance(balances, last_value); + let encoded = encode_raw_balance(&raw); + return json!({ + "jsonrpc": "2.0", + "id": req["id"].clone(), + "result": encoded, + }); + } + + mock_rpc_response(req, 42431) +} + +fn next_balance( + balances: &Arc>>, + last_value: &Arc>, +) -> String { + let next = { + let mut queue = balances.lock().unwrap(); + queue.pop_front() + }; + + match next { + Some(raw) => { + *last_value.lock().unwrap() = raw.clone(); + raw + } + None => last_value.lock().unwrap().clone(), + } +} + +fn encode_raw_balance(raw: &str) -> String { + let value = raw.parse::().unwrap(); + let bytes = alloy::primitives::U256::from(value).to_be_bytes::<32>(); + format!("0x{}", hex::encode(bytes)) +} + +fn moderato_config_toml(rpc_url: &str) -> String { + format!("[rpc]\n\"tempo-moderato\" = \"{rpc_url}\"\n") +} + +fn build_login_temp(rpc_url: &str) -> tempfile::TempDir { + TestConfigBuilder::new() + .with_config_toml(moderato_config_toml(rpc_url)) + .build() +} + +fn build_fund_temp(rpc_url: &str) -> tempfile::TempDir { + TestConfigBuilder::new() + .with_config_toml(moderato_config_toml(rpc_url)) + .with_keys_toml(MODERATO_DIRECT_KEYS_TOML) + .build() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "login should succeed: {:?}", + output + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Auth URL:"), "{stderr}"); + assert!(stderr.contains("Verification code:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!( + stderr.contains("If the wallet page shows that same code"), + "{stderr}" + ); + assert!(stderr.contains("tap Continue"), "{stderr}"); + assert!( + stderr.contains("After passkey or wallet creation, return here"), + "{stderr}" + ); + assert!(stderr.contains("one more authorization link"), "{stderr}"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Wallet"), "{stdout}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-n", "tempo-moderato", "login"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "login should succeed: {:?}", + output + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Auth URL:"), "{stderr}"); + assert!(stderr.contains("Verification code:"), "{stderr}"); + assert!( + !stderr.contains("Open this link on your device"), + "unexpected remote-safe handoff text: {stderr}" + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Wallet"), "{stdout}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_change() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {:?}", output); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!(stderr.contains("After funding is complete"), "{stderr}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-n", "tempo-moderato", "fund"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {:?}", output); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!( + !stderr.contains("Open this link on your device"), + "unexpected remote-safe handoff text: {stderr}" + ); +} From 357cbe92fc78f5fb361dd5c3c775b90228e664c8 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:10:24 -0700 Subject: [PATCH 02/10] test(wallet): tighten remote flow harness checks --- crates/tempo-wallet/tests/remote_flows.rs | 111 +++++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs index e10564a1..a78cc3e5 100644 --- a/crates/tempo-wallet/tests/remote_flows.rs +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -11,7 +11,10 @@ use tempo_test::{mock_rpc_response, TestConfigBuilder, MODERATO_DIRECT_KEYS_TOML use common::test_command; -#[allow(dead_code)] +const AUTHORIZED_WALLET_ADDRESS: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; +const MODERATO_TOKEN_ADDRESS: &str = "0x20c0000000000000000000000000000000000000"; +const BALANCE_OF_SELECTOR: &str = "70a08231"; + struct MockLoginServer { base_url: String, poll_count: Arc>, @@ -54,7 +57,7 @@ impl MockLoginServer { *count += 1; json!({ "status": "authorized", - "account_address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "account_address": AUTHORIZED_WALLET_ADDRESS, "key_authorization": null }) }; @@ -95,7 +98,6 @@ impl Drop for MockLoginServer { } } -#[allow(dead_code)] struct BalanceSequenceRpcServer { base_url: String, balances: Arc>>, @@ -181,7 +183,7 @@ fn handle_rpc_request( balances: &Arc>>, last_value: &Arc>, ) -> serde_json::Value { - if req["method"].as_str() == Some("eth_call") { + if is_fund_balance_request(req) { let raw = next_balance(balances, last_value); let encoded = encode_raw_balance(&raw); return json!({ @@ -194,6 +196,32 @@ fn handle_rpc_request( mock_rpc_response(req, 42431) } +fn is_fund_balance_request(req: &serde_json::Value) -> bool { + if req["method"].as_str() != Some("eth_call") { + return false; + } + + let Some(params) = req["params"].as_array() else { + return false; + }; + let Some(call) = params.first().and_then(serde_json::Value::as_object) else { + return false; + }; + let Some(to) = call.get("to").and_then(serde_json::Value::as_str) else { + return false; + }; + let Some(data) = call + .get("data") + .or_else(|| call.get("input")) + .and_then(serde_json::Value::as_str) + else { + return false; + }; + + normalized_hex(to) == normalized_hex(MODERATO_TOKEN_ADDRESS) + && data.eq_ignore_ascii_case(&balance_of_call_data(AUTHORIZED_WALLET_ADDRESS)) +} + fn next_balance( balances: &Arc>>, last_value: &Arc>, @@ -218,6 +246,14 @@ fn encode_raw_balance(raw: &str) -> String { format!("0x{}", hex::encode(bytes)) } +fn normalized_hex(value: &str) -> String { + value.trim_start_matches("0x").to_ascii_lowercase() +} + +fn balance_of_call_data(account: &str) -> String { + format!("0x{BALANCE_OF_SELECTOR}{:0>64}", normalized_hex(account)) +} + fn moderato_config_toml(rpc_url: &str) -> String { format!("[rpc]\n\"tempo-moderato\" = \"{rpc_url}\"\n") } @@ -235,6 +271,67 @@ fn build_fund_temp(rpc_url: &str) -> tempfile::TempDir { .build() } +#[test] +fn unrelated_eth_call_uses_default_rpc_response_and_does_not_advance_balance_sequence() { + let balances = Arc::new(Mutex::new(VecDeque::from([ + String::from("0"), + String::from("1000000"), + ]))); + let last_value = Arc::new(Mutex::new(String::from("0"))); + let request = json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "eth_call", + "params": [ + { + "to": "0xe1c4d3dce17bc111181ddf716f75bae49e61a336", + "data": "0x12345678" + }, + "latest" + ] + }); + + let response = handle_rpc_request(&request, &balances, &last_value); + + assert_eq!(response, mock_rpc_response(&request, 42431)); + assert_eq!( + balances.lock().unwrap().iter().cloned().collect::>(), + vec![String::from("0"), String::from("1000000")] + ); + assert_eq!(last_value.lock().unwrap().as_str(), "0"); +} + +#[test] +fn matching_balance_request_advances_sequence_and_repeats_last_value() { + let balances = Arc::new(Mutex::new(VecDeque::from([ + String::from("0"), + String::from("1000000"), + ]))); + let last_value = Arc::new(Mutex::new(String::from("0"))); + let request = json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "eth_call", + "params": [ + { + "to": MODERATO_TOKEN_ADDRESS, + "input": balance_of_call_data(AUTHORIZED_WALLET_ADDRESS) + }, + "latest" + ] + }); + + let first = handle_rpc_request(&request, &balances, &last_value); + let second = handle_rpc_request(&request, &balances, &last_value); + let third = handle_rpc_request(&request, &balances, &last_value); + + assert_eq!(first["result"], encode_raw_balance("0")); + assert_eq!(second["result"], encode_raw_balance("1000000")); + assert_eq!(third["result"], encode_raw_balance("1000000")); + assert!(balances.lock().unwrap().is_empty()); + assert_eq!(last_value.lock().unwrap().as_str(), "1000000"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { let login = MockLoginServer::start_authorized("ANMGE375").await; @@ -269,6 +366,7 @@ async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Wallet"), "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -298,6 +396,7 @@ async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_t let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Wallet"), "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -319,6 +418,8 @@ async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_cha assert!(stderr.contains("Fund URL:"), "{stderr}"); assert!(stderr.contains("Open this link on your device"), "{stderr}"); assert!(stderr.contains("After funding is complete"), "{stderr}"); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -342,4 +443,6 @@ async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_te !stderr.contains("Open this link on your device"), "unexpected remote-safe handoff text: {stderr}" ); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } From a7d9239d75028193dcd98d07302e6519c0b94d0c Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:18:32 -0700 Subject: [PATCH 03/10] feat(wallet): add no-browser command plumbing --- crates/tempo-wallet/src/app.rs | 9 ++- crates/tempo-wallet/src/args.rs | 9 ++- crates/tempo-wallet/src/commands/auth.rs | 67 ++++++++++++++++++-- crates/tempo-wallet/src/commands/fund/mod.rs | 37 +++++++++-- crates/tempo-wallet/src/commands/login.rs | 31 ++++++--- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/crates/tempo-wallet/src/app.rs b/crates/tempo-wallet/src/app.rs index 3da79c92..38fb104c 100644 --- a/crates/tempo-wallet/src/app.rs +++ b/crates/tempo-wallet/src/app.rs @@ -25,11 +25,14 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { |ctx| async move { let cmd_name = command_name(&command); let result = match command { - Commands::Login => login::run(&ctx).await, + Commands::Login { no_browser } => login::run(&ctx, no_browser).await, Commands::Refresh => refresh::run(&ctx).await, Commands::Logout { yes } => logout::run(&ctx, yes), Commands::Completions { shell } => completions::run(&ctx, shell), - Commands::Fund { address } => fund::run(&ctx, address).await, + Commands::Fund { + address, + no_browser, + } => fund::run(&ctx, address, no_browser).await, Commands::Whoami => whoami::run(&ctx).await, Commands::Keys => keys::run(&ctx).await, Commands::Sessions { command } => { @@ -63,7 +66,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { /// Derive a short analytics-friendly name from a parsed command. const fn command_name(command: &Commands) -> &'static str { match command { - Commands::Login => "login", + Commands::Login { .. } => "login", Commands::Refresh => "refresh", Commands::Logout { .. } => "logout", Commands::Completions { .. } => "completions", diff --git a/crates/tempo-wallet/src/args.rs b/crates/tempo-wallet/src/args.rs index 14c3dd0b..e7438462 100644 --- a/crates/tempo-wallet/src/args.rs +++ b/crates/tempo-wallet/src/args.rs @@ -31,7 +31,11 @@ pub(crate) struct Cli { pub(crate) enum Commands { /// Sign up or log in to your Tempo wallet #[command(display_order = 1)] - Login, + Login { + /// Do not attempt to open a browser + #[arg(long)] + no_browser: bool, + }, /// Refresh your access key without logging out #[command(display_order = 2)] Refresh, @@ -74,6 +78,9 @@ Examples: /// Wallet address to fund (defaults to current wallet) #[arg(long)] address: Option, + /// Do not attempt to open a browser + #[arg(long)] + no_browser: bool, }, /// Manage payment sessions #[command(display_order = 8, name = "sessions")] diff --git a/crates/tempo-wallet/src/commands/auth.rs b/crates/tempo-wallet/src/commands/auth.rs index 23bc530b..542e21be 100644 --- a/crates/tempo-wallet/src/commands/auth.rs +++ b/crates/tempo-wallet/src/commands/auth.rs @@ -1,10 +1,69 @@ //! Shared authentication and browser utilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BrowserLaunchStatus { + Opened, + Skipped, + Failed, +} + /// Attempt to open a URL in the default browser. /// Prints a fallback message if it fails. -pub(crate) fn try_open_browser(url: &str) { - if let Err(e) = webbrowser::open(url) { - eprintln!("Failed to open browser: {e}"); - eprintln!("Open this URL manually: {url}"); +fn try_open_browser_with( + url: &str, + no_browser: bool, + opener: impl FnOnce(&str) -> Result<(), String>, +) -> BrowserLaunchStatus { + if no_browser { + return BrowserLaunchStatus::Skipped; + } + match opener(url) { + Ok(_) => BrowserLaunchStatus::Opened, + Err(err) => { + eprintln!("Failed to open browser: {err}"); + eprintln!("Open this URL manually: {url}"); + BrowserLaunchStatus::Failed + } + } +} + +pub(crate) fn try_open_browser(url: &str, no_browser: bool) -> BrowserLaunchStatus { + try_open_browser_with(url, no_browser, |url| { + webbrowser::open(url).map_err(|err| err.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::{try_open_browser_with, BrowserLaunchStatus}; + use std::{cell::RefCell, rc::Rc}; + + #[test] + fn browser_launch_is_skipped_when_no_browser_is_true() { + let status = try_open_browser_with( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + true, + |_url| Err("should not be called".to_string()), + ); + assert_eq!(status, BrowserLaunchStatus::Skipped); + } + + #[test] + fn browser_launch_attempts_open_when_no_browser_is_false() { + let seen_url = Rc::new(RefCell::new(None::)); + let captured_url = Rc::clone(&seen_url); + let status = try_open_browser_with( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + false, + |url| { + *captured_url.borrow_mut() = Some(url.to_string()); + Err("synthetic open failure".to_string()) + }, + ); + assert_eq!(status, BrowserLaunchStatus::Failed); + assert_eq!( + seen_url.borrow().as_deref(), + Some("https://wallet.tempo.xyz/cli-auth?code=ANMGE375") + ); } } diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index f9013560..79789539 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -26,15 +26,23 @@ const CALLBACK_TIMEOUT_SECS: u64 = 900; // Entry point // --------------------------------------------------------------------------- -pub(crate) async fn run(ctx: &Context, address: Option) -> Result<(), TempoError> { - let method = "browser"; +pub(crate) async fn run( + ctx: &Context, + address: Option, + no_browser: bool, +) -> Result<(), TempoError> { + let method = fund_method(no_browser); track_fund_start(ctx, method); - let result = run_inner(ctx, address).await; + let result = run_inner(ctx, address, no_browser).await; track_fund_result(ctx, method, &result); result } -async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoError> { +async fn run_inner( + ctx: &Context, + address: Option, + no_browser: bool, +) -> Result<(), TempoError> { let wallet_address = resolve_address(address, &ctx.keys)?; let before = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; @@ -53,7 +61,7 @@ async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoEr eprintln!("Fund URL: {fund_url}"); } - super::auth::try_open_browser(&fund_url); + super::auth::try_open_browser(&fund_url, no_browser); if ctx.output_format == OutputFormat::Text { eprintln!("Waiting for funding..."); @@ -133,6 +141,14 @@ fn render_balance_diff(before: &[TokenBalance], after: &[TokenBalance]) { } } +fn fund_method(no_browser: bool) -> &'static str { + if no_browser { + "manual" + } else { + "browser" + } +} + // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- @@ -170,3 +186,14 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError } } } + +#[cfg(test)] +mod tests { + use super::fund_method; + + #[test] + fn fund_method_uses_manual_only_when_no_browser_is_true() { + assert_eq!(fund_method(true), "manual"); + assert_eq!(fund_method(false), "browser"); + } +} diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index 9026ecb7..16a49dff 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -12,6 +12,7 @@ use zeroize::Zeroizing; use super::whoami::show_whoami; use crate::analytics::{self, CallbackReceivedPayload, LoginFailurePayload, WalletCreatedPayload}; +use crate::commands::auth::BrowserLaunchStatus; use tempo_common::{ cli::{context::Context, output::OutputFormat}, error::{ConfigError, InputError, KeyError, NetworkError, TempoError}, @@ -23,15 +24,15 @@ use tempo_common::{ const CALLBACK_TIMEOUT_SECS: u64 = 900; // 15 minutes const POLL_INTERVAL_SECS: u64 = 2; -pub(crate) async fn run(ctx: &Context) -> Result<(), TempoError> { - run_impl(ctx, false).await +pub(crate) async fn run(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { + run_impl(ctx, false, no_browser).await } pub(crate) async fn run_with_reauth(ctx: &Context) -> Result<(), TempoError> { - run_impl(ctx, true).await + run_impl(ctx, true, false).await } -async fn run_impl(ctx: &Context, force_reauth: bool) -> Result<(), TempoError> { +async fn run_impl(ctx: &Context, force_reauth: bool, no_browser: bool) -> Result<(), TempoError> { ctx.track_event(analytics::LOGIN_STARTED); let already_logged_in = ctx.keys.has_key_for_network(ctx.network); @@ -54,7 +55,7 @@ async fn run_impl(ctx: &Context, force_reauth: bool) -> Result<(), TempoError> { } if !already_logged_in || needs_reauth { - let result = do_login(ctx).await; + let result = do_login(ctx, no_browser).await; if let Some(ref a) = ctx.analytics { track_login_result(a, &result); @@ -195,7 +196,7 @@ fn track_login_result(a: &tempo_common::analytics::Analytics, result: &Result<() } } -async fn do_login(ctx: &Context) -> Result<(), TempoError> { +async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { let auth_server_url = std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); @@ -229,13 +230,15 @@ async fn do_login(ctx: &Context) -> Result<(), TempoError> { // Always attempt browser open, even in machine output modes. // Some agents run login with non-text output (`-t`/JSON) and still need // the browser flow to start. - super::auth::try_open_browser(&url_str); + let browser_launch_status = super::auth::try_open_browser(&url_str, no_browser); if ctx.output_format == OutputFormat::Text { show_login_prompt(&code); } - ctx.track_event(analytics::CALLBACK_WINDOW_OPENED); + if should_track_callback_window(browser_launch_status) { + ctx.track_event(analytics::CALLBACK_WINDOW_OPENED); + } let callback = poll_until_authorized(&client, &auth_base_url, &code, &code_verifier).await?; @@ -274,6 +277,10 @@ fn show_login_prompt(code: &str) { eprintln!("Waiting for authentication..."); } +fn should_track_callback_window(status: BrowserLaunchStatus) -> bool { + matches!(status, BrowserLaunchStatus::Opened) +} + struct AuthCallback { account_address: String, key_authorization: Option, @@ -492,6 +499,7 @@ fn generate_pkce_pair() -> Result<(String, String), TempoError> { #[cfg(test)] mod tests { use super::*; + use crate::commands::auth::BrowserLaunchStatus; #[test] fn test_pkce_pair_lengths() { @@ -530,4 +538,11 @@ mod tests { let (v2, _) = generate_pkce_pair().expect("pkce generation should succeed"); assert_ne!(v1, v2); } + + #[test] + fn callback_window_is_only_tracked_when_browser_launch_opens() { + assert!(should_track_callback_window(BrowserLaunchStatus::Opened)); + assert!(!should_track_callback_window(BrowserLaunchStatus::Skipped)); + assert!(!should_track_callback_window(BrowserLaunchStatus::Failed)); + } } From 39f2e9acc5f114ce3cfdf0bee5441f1d89f77e63 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:20:07 -0700 Subject: [PATCH 04/10] feat(wallet): add remote login handoff guidance --- crates/tempo-wallet/src/commands/login.rs | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index 16a49dff..dc32e91d 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -233,7 +233,11 @@ async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { let browser_launch_status = super::auth::try_open_browser(&url_str, no_browser); if ctx.output_format == OutputFormat::Text { - show_login_prompt(&code); + if no_browser { + show_remote_login_prompt(&url_str, &code); + } else { + show_login_prompt(&code); + } } if should_track_callback_window(browser_launch_status) { @@ -267,16 +271,33 @@ async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { /// Display the verification code and wait prompt for authentication. fn show_login_prompt(code: &str) { - let display_code = if code.len() == 8 { - format!("{}-{}", &code[..4], &code[4..]) - } else { - code.to_string() - }; + let display_code = format_verification_code(code); + eprintln!("Verification code: {}", display_code.bold()); + eprintln!(); + eprintln!("Waiting for authentication..."); +} + +/// Display the remote-host handoff prompt for a user who is chatting from another device. +fn show_remote_login_prompt(auth_url: &str, code: &str) { + let display_code = format_verification_code(code); + eprintln!("Open this link on your device: {auth_url}"); eprintln!("Verification code: {}", display_code.bold()); + eprintln!("If the wallet page shows that same code, tap Continue."); + eprintln!( + "After passkey or wallet creation, return here and message me. I may need to send one more authorization link so this host can use the wallet." + ); eprintln!(); eprintln!("Waiting for authentication..."); } +fn format_verification_code(code: &str) -> String { + if code.len() == 8 { + format!("{}-{}", &code[..4], &code[4..]) + } else { + code.to_string() + } +} + fn should_track_callback_window(status: BrowserLaunchStatus) -> bool { matches!(status, BrowserLaunchStatus::Opened) } From 2e7c385e174a589580962744caeca74f7bd1f3fc Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:21:39 -0700 Subject: [PATCH 05/10] feat(wallet): add remote funding handoff guidance --- crates/tempo-wallet/src/commands/fund/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index 79789539..4b7dd130 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -64,6 +64,9 @@ async fn run_inner( super::auth::try_open_browser(&fund_url, no_browser); if ctx.output_format == OutputFormat::Text { + if no_browser { + show_remote_fund_prompt(&fund_url); + } eprintln!("Waiting for funding..."); } @@ -149,6 +152,11 @@ fn fund_method(no_browser: bool) -> &'static str { } } +fn show_remote_fund_prompt(fund_url: &str) { + eprintln!("Open this link on your device: {fund_url}"); + eprintln!("After funding is complete, return here and message me."); +} + // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- From 2769b09e31ed1aec104b443c200f673f57d80497 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:23:46 -0700 Subject: [PATCH 06/10] fix(wallet): neutralize remote handoff copy --- crates/tempo-wallet/src/commands/fund/mod.rs | 2 +- crates/tempo-wallet/src/commands/login.rs | 50 +++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index 4b7dd130..8f382083 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -154,7 +154,7 @@ fn fund_method(no_browser: bool) -> &'static str { fn show_remote_fund_prompt(fund_url: &str) { eprintln!("Open this link on your device: {fund_url}"); - eprintln!("After funding is complete, return here and message me."); + eprintln!("After funding is complete, return here to continue."); } // --------------------------------------------------------------------------- diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index dc32e91d..e0e74686 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -279,17 +279,32 @@ fn show_login_prompt(code: &str) { /// Display the remote-host handoff prompt for a user who is chatting from another device. fn show_remote_login_prompt(auth_url: &str, code: &str) { - let display_code = format_verification_code(code); - eprintln!("Open this link on your device: {auth_url}"); - eprintln!("Verification code: {}", display_code.bold()); - eprintln!("If the wallet page shows that same code, tap Continue."); - eprintln!( - "After passkey or wallet creation, return here and message me. I may need to send one more authorization link so this host can use the wallet." - ); + let prompt = remote_login_prompt(auth_url, code); + eprintln!("{}", prompt.auth_url_line); + eprintln!("Verification code: {}", prompt.verification_code.bold()); + eprintln!("{}", prompt.continue_line); + eprintln!("{}", prompt.return_line); eprintln!(); eprintln!("Waiting for authentication..."); } +struct RemoteLoginPrompt { + auth_url_line: String, + verification_code: String, + continue_line: &'static str, + return_line: &'static str, +} + +fn remote_login_prompt(auth_url: &str, code: &str) -> RemoteLoginPrompt { + RemoteLoginPrompt { + auth_url_line: format!("Open this link on your device: {auth_url}"), + verification_code: format_verification_code(code), + continue_line: "If the wallet page shows that same code, tap Continue.", + return_line: + "After passkey or wallet creation, return here. If needed, one more authorization link may still be required before this host is ready.", + } +} + fn format_verification_code(code: &str) -> String { if code.len() == 8 { format!("{}-{}", &code[..4], &code[4..]) @@ -566,4 +581,25 @@ mod tests { assert!(!should_track_callback_window(BrowserLaunchStatus::Skipped)); assert!(!should_track_callback_window(BrowserLaunchStatus::Failed)); } + + #[test] + fn remote_login_prompt_covers_required_remote_handoff_steps() { + let prompt = remote_login_prompt( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + "ANMGE375", + ); + + assert_eq!( + prompt.auth_url_line, + "Open this link on your device: https://wallet.tempo.xyz/cli-auth?code=ANMGE375" + ); + assert_eq!(prompt.verification_code, "ANMG-E375"); + assert_eq!( + prompt.continue_line, + "If the wallet page shows that same code, tap Continue." + ); + assert!(prompt.return_line.contains("return here")); + assert!(prompt.return_line.contains("one more authorization link")); + assert!(prompt.return_line.contains("this host is ready")); + } } From 13ec8c46aa5e9fb6da2e6aa2d9960c787c50cc42 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:23:48 -0700 Subject: [PATCH 07/10] docs(wallet): document remote handoff flow --- README.md | 8 ++++++++ SKILL.md | 6 +++++- crates/tempo-wallet/README.md | 13 +++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4487f86e..a807c67b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Tempo Wallet is a CLI that lets you create a wallet, manage keys, and make HTTP 3. The browser authorizes a session key for the CLI and redirects back. 4. The CLI stores the authorized key locally. All subsequent signing happens locally — no browser needed. +If the agent is running on a remote host while you are using a different device, use `tempo wallet login --no-browser` instead. The CLI will print the auth URL and verification code for you to open on your device, and you may need to return to your CLI or agent session after passkey creation or after funding. A second authorization round may still be needed before the host is ready. + ## Goals 1. **Zero-config payments**: `tempo request ` handles the full 402 flow — challenge, sign, pay, retry — in a single command. @@ -60,11 +62,17 @@ npx skills@latest add tempoxyz/wallet --global # Log in with your passkey (opens browser) tempo wallet login +# Remote-host login when the human is on another device +tempo wallet login --no-browser + # Check your wallet tempo wallet whoami # Fund your wallet tempo wallet fund + +# Remote-host funding when the human is on another device +tempo wallet fund --no-browser ``` ### One-Shot Payment (Charge) diff --git a/SKILL.md b/SKILL.md index a00b93ad..270dd217 100644 --- a/SKILL.md +++ b/SKILL.md @@ -16,7 +16,9 @@ Run these commands in order. Do not skip steps. Do not search for other install **Step 1 — Install:** `curl -fsSL https://tempo.xyz/install | bash` -**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — requires user browser/passkey action. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes). +**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — default local flow that opens a browser on the host. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes). + +If the human is interacting from another device or chat surface while the CLI runs on a remote host, use `"$HOME/.tempo/bin/tempo" wallet login --no-browser` instead. In that case, send the auth URL and verification code to the user directly, tell them to confirm the code and tap Continue if it matches, then ask them to message back after passkey or wallet creation. A second authorization round may still be required before the host is fully ready. **Step 3 — Confirm readiness:** `"$HOME/.tempo/bin/tempo" wallet -t whoami` @@ -32,6 +34,8 @@ Provide: - Installation location and version (`$HOME/.tempo/bin/tempo --version`). - Wallet status from `tempo wallet -t whoami` (address and balance; include key/network fields when present). - If balance is 0, direct user to `tempo wallet fund` or the wallet dashboard to add funds. +- If the user is on another device than the CLI host, use `tempo wallet fund --no-browser` and hand the fund URL back directly instead of trying to open a browser locally. +- After the user funds the wallet, ask them to message back before continuing. - 2-3 simple starter prompts tailored to currently available services. To generate starter prompts, list available services and pick useful beginner examples: diff --git a/crates/tempo-wallet/README.md b/crates/tempo-wallet/README.md index 3cc6177f..45460665 100644 --- a/crates/tempo-wallet/README.md +++ b/crates/tempo-wallet/README.md @@ -7,11 +7,13 @@ Wallet identity and custody extension for the Tempo CLI. Manages authentication, | Command | Description | |---------|-------------| | `tempo wallet login` | Connect via browser passkey authentication | +| `tempo wallet login --no-browser` | Remote-host login when the human is on another device | | `tempo wallet refresh` | Renew your access key without logging out | | `tempo wallet logout` | Disconnect your wallet | | `tempo wallet whoami` | Show wallet address, balances, keys, and readiness | | `tempo wallet keys` | List keys with balance and spending limit details | | `tempo wallet fund` | Fund your wallet (opens browser) | +| `tempo wallet fund --no-browser` | Remote-host funding when the human is on another device | | `tempo wallet sessions list` | List payment sessions | | `tempo wallet sessions close` | Close by origin or channel ID, or batch close/finalize/orphaned | | `tempo wallet sessions sync` | Reconcile local sessions against on-chain state | @@ -27,10 +29,21 @@ curl -fsSL https://tempo.xyz/install | bash # Connect your wallet tempo wallet login +# Remote-host login +tempo wallet login --no-browser + # Check status tempo wallet whoami + +# Fund your wallet +tempo wallet fund + +# Remote-host funding +tempo wallet fund --no-browser ``` +If you use the remote-host funding path, return to your CLI or agent session once funding is complete so the workflow can continue. + ## License Dual-licensed under [Apache 2.0](../../LICENSE-APACHE) and [MIT](../../LICENSE-MIT). From de78baf089a8c0922590c129b3bd91a9c818f780 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 24 Mar 2026 22:27:17 -0700 Subject: [PATCH 08/10] test(wallet): satisfy clippy in remote flow coverage --- crates/tempo-wallet/tests/remote_flows.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs index a78cc3e5..4e577ad4 100644 --- a/crates/tempo-wallet/tests/remote_flows.rs +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -344,11 +344,7 @@ async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { .output() .unwrap(); - assert!( - output.status.success(), - "login should succeed: {:?}", - output - ); + assert!(output.status.success(), "login should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Auth URL:"), "{stderr}"); assert!(stderr.contains("Verification code:"), "{stderr}"); @@ -381,11 +377,7 @@ async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_t .output() .unwrap(); - assert!( - output.status.success(), - "login should succeed: {:?}", - output - ); + assert!(output.status.success(), "login should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Auth URL:"), "{stderr}"); assert!(stderr.contains("Verification code:"), "{stderr}"); @@ -413,7 +405,7 @@ async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_cha .output() .unwrap(); - assert!(output.status.success(), "fund should succeed: {:?}", output); + assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Fund URL:"), "{stderr}"); assert!(stderr.contains("Open this link on your device"), "{stderr}"); @@ -436,7 +428,7 @@ async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_te .output() .unwrap(); - assert!(output.status.success(), "fund should succeed: {:?}", output); + assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Fund URL:"), "{stderr}"); assert!( From fb9fd027633db1d35f1bf8a82c996b22ff4594e1 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Thu, 26 Mar 2026 16:08:02 -0700 Subject: [PATCH 09/10] fix(wallet): preserve no-browser handoff in structured modes --- crates/tempo-wallet/src/commands/fund/mod.rs | 16 +-- crates/tempo-wallet/src/commands/login.rs | 10 +- crates/tempo-wallet/tests/remote_flows.rs | 125 ++++++++++++++++--- 3 files changed, 122 insertions(+), 29 deletions(-) diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index 8f382083..9d5b638b 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -56,17 +56,19 @@ async fn run_inner( })?; let base_url = parsed_url.origin().ascii_serialization(); let fund_url = format!("{base_url}/?action=fund"); + let show_status = no_browser || ctx.output_format == OutputFormat::Text; - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!("Fund URL: {fund_url}"); } super::auth::try_open_browser(&fund_url, no_browser); - if ctx.output_format == OutputFormat::Text { - if no_browser { - show_remote_fund_prompt(&fund_url); - } + if no_browser { + show_remote_fund_prompt(&fund_url); + } + + if show_status { eprintln!("Waiting for funding..."); } @@ -76,7 +78,7 @@ async fn run_inner( loop { if start.elapsed() >= timeout { - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!( "Timed out waiting for funding after {} minutes.", CALLBACK_TIMEOUT_SECS / 60 @@ -90,7 +92,7 @@ async fn run_inner( let current = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; if has_balance_changed(&before, ¤t) { - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!("\nFunding received!"); render_balance_diff(&before, ¤t); } diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index e0e74686..d9928146 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -232,12 +232,10 @@ async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { // the browser flow to start. let browser_launch_status = super::auth::try_open_browser(&url_str, no_browser); - if ctx.output_format == OutputFormat::Text { - if no_browser { - show_remote_login_prompt(&url_str, &code); - } else { - show_login_prompt(&code); - } + if no_browser { + show_remote_login_prompt(&url_str, &code); + } else if ctx.output_format == OutputFormat::Text { + show_login_prompt(&code); } if should_track_callback_window(browser_launch_status) { diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs index 4e577ad4..a88a5994 100644 --- a/crates/tempo-wallet/tests/remote_flows.rs +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -196,6 +196,28 @@ fn handle_rpc_request( mock_rpc_response(req, 42431) } +fn assert_remote_login_handoff(stderr: &str) { + assert!(stderr.contains("Auth URL:"), "{stderr}"); + assert!(stderr.contains("Verification code:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!( + stderr.contains("If the wallet page shows that same code"), + "{stderr}" + ); + assert!(stderr.contains("tap Continue"), "{stderr}"); + assert!( + stderr.contains("After passkey or wallet creation, return here"), + "{stderr}" + ); + assert!(stderr.contains("one more authorization link"), "{stderr}"); +} + +fn assert_remote_fund_handoff(stderr: &str) { + assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!(stderr.contains("After funding is complete"), "{stderr}"); +} + fn is_fund_balance_request(req: &serde_json::Value) -> bool { if req["method"].as_str() != Some("eth_call") { return false; @@ -346,25 +368,56 @@ async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { assert!(output.status.success(), "login should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Auth URL:"), "{stderr}"); - assert!(stderr.contains("Verification code:"), "{stderr}"); - assert!(stderr.contains("Open this link on your device"), "{stderr}"); - assert!( - stderr.contains("If the wallet page shows that same code"), - "{stderr}" - ); - assert!(stderr.contains("tap Continue"), "{stderr}"); - assert!( - stderr.contains("After passkey or wallet creation, return here"), - "{stderr}" - ); - assert!(stderr.contains("one more authorization link"), "{stderr}"); + assert_remote_login_handoff(&stderr); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Wallet"), "{stdout}"); assert_eq!(*login.poll_count.lock().unwrap(), 2); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_json_keeps_structured_stdout_and_prints_remote_handoff() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-j", "-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_login_handoff(&stderr); + + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["ready"], true, "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_toon_keeps_structured_stdout_and_prints_remote_handoff() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-t", "-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_login_handoff(&stderr); + + let stdout: serde_json::Value = + toon_format::decode_default(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); + assert_eq!(stdout["ready"], true, "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { let login = MockLoginServer::start_authorized("ANMGE375").await; @@ -407,9 +460,49 @@ async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_cha assert!(output.status.success(), "fund should succeed: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Fund URL:"), "{stderr}"); - assert!(stderr.contains("Open this link on your device"), "{stderr}"); - assert!(stderr.contains("After funding is complete"), "{stderr}"); + assert_remote_fund_handoff(&stderr); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_json_prints_remote_handoff() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-j", "-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_toon_prints_remote_handoff() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-t", "-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr); assert!(rpc.balances.lock().unwrap().is_empty()); assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); } From cd24f87145eb57971ab2412b48824fc193298c99 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Fri, 27 Mar 2026 07:54:02 -0700 Subject: [PATCH 10/10] chore(wallet): add changelog and fmt follow-up --- .changelog/remote-wallet-no-browser.md | 5 +++++ crates/tempo-wallet/src/commands/login.rs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changelog/remote-wallet-no-browser.md diff --git a/.changelog/remote-wallet-no-browser.md b/.changelog/remote-wallet-no-browser.md new file mode 100644 index 00000000..9047aec4 --- /dev/null +++ b/.changelog/remote-wallet-no-browser.md @@ -0,0 +1,5 @@ +--- +tempo-wallet: patch +--- + +Add a remote-host-friendly `--no-browser` path for `tempo wallet login` and `tempo wallet fund`, including CLI guidance that agents can relay to a user approving wallet actions from another device. diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index d9928146..a5540821 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -11,8 +11,10 @@ use url::Url; use zeroize::Zeroizing; use super::whoami::show_whoami; -use crate::analytics::{self, CallbackReceivedPayload, LoginFailurePayload, WalletCreatedPayload}; -use crate::commands::auth::BrowserLaunchStatus; +use crate::{ + analytics::{self, CallbackReceivedPayload, LoginFailurePayload, WalletCreatedPayload}, + commands::auth::BrowserLaunchStatus, +}; use tempo_common::{ cli::{context::Context, output::OutputFormat}, error::{ConfigError, InputError, KeyError, NetworkError, TempoError},