From f2fd190081c564ff26c5ebf082ba686d028f1bda Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 10:48:13 +0200 Subject: [PATCH 1/6] feat(host-cli): notify removed devices --- rust/crates/truapi-host-cli/README.md | 15 +- rust/crates/truapi-host-cli/SPEC.md | 7 +- .../e2e/device-removal-disconnect.sh | 204 ++++++++++++++++++ .../js/scripts/device-removal-disconnect.ts | 42 ++++ rust/crates/truapi-host-cli/src/main.rs | 63 ++++-- .../truapi-host-cli/src/signing_shell.rs | 2 +- .../truapi-host-cli/tests/signing_host_cli.rs | 29 +-- rust/crates/truapi-server/src/host_core.rs | 160 +++++++++++++- rust/crates/truapi-server/src/runtime.rs | 4 +- .../truapi-server/src/runtime/signing_host.rs | 3 +- .../src/runtime/signing_host/sso_responder.rs | 29 ++- 11 files changed, 512 insertions(+), 46 deletions(-) create mode 100755 rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh create mode 100644 rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index c8cb725da..656db8b72 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -210,7 +210,7 @@ Commands always start with `/`: | `/pair ` | Decode a pairing QR from a PNG, JPEG, or WebP image (signing host). | | `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | | `/devices` or `/devices --list` | List every paired device saved for the active signing-host session. | -| `/devices --remove ` | Remove one paired device by its 32-byte statement account ID. | +| `/devices --remove ` | Disconnect and remove one paired device by its 32-byte statement account ID. | | `/approval` | Show whether signing-host confirmations are manual or automatic. | | `/approval manual` | Prompt for every future signing-host confirmation. | | `/approval automatic` | Approve every future signing-host confirmation automatically. | @@ -382,8 +382,11 @@ statement lifetime. order with available host and platform metadata. Interactive `/devices --remove ` asks for confirmation. The same command through `exec` is an explicit one-shot removal and runs without another -prompt. Removing one device stops only its responder and allowance renewal. The -other saved pairings and the signing identity are unchanged. +prompt. Removal first submits `Disconnected` to the selected remote host. Only +after the statement is accepted does it stop that responder, remove the saved +pairing, and stop its allowance renewal. A submission failure preserves all +local pairing state. The other saved pairings and the signing identity are +unchanged. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` @@ -557,6 +560,12 @@ Six scripts ship under `js/scripts/`: --auto-accept ``` + `e2e/device-removal-disconnect.sh` automates the two-host removal case. It + pairs an isolated signing host with an isolated pairing host, removes the + device interactively, and verifies the remote `Disconnected` status, cleared + pairing auth storage, and empty signing-host device list. Run `make codegen` + once in a fresh checkout, build `truapi-host-cli`, then run the script. + - `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this remains available as an explicit `/script ` example. - `signing-smoke.ts` — a focused product-account signing check. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 8babd2479..7385bbc36 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -427,7 +427,8 @@ use `exec '/script '` instead. `/copy` and `/approval` are unavailable. `exec '/devices'` and `exec '/devices --list'` inspect the selected session's saved pairings without starting their responders. `exec '/devices --remove '` is an explicit removal and does not ask for another -confirmation. +confirmation. It submits `Disconnected` directly and removes local state only +after the statement is accepted. ### 6.5 `--serve` @@ -532,7 +533,7 @@ Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. | `/pair ` | no | yes | Validate and answer a `polkadotapp://pair?...` link. | | `/devices` | no | yes | List paired devices saved for the active managed session. | | `/devices --list` | no | yes | List paired devices saved for the active managed session. | -| `/devices --remove ` | no | yes | Remove one paired device by its 32-byte statement account ID. | +| `/devices --remove ` | no | yes | Disconnect and remove one paired device by its 32-byte statement account ID. | | `/approval` | no | yes | Print the current manual or automatic approval mode. TUI only. | | `/approval manual` | no | yes | Prompt for every future confirmation. TUI only. | | `/approval automatic` | no | yes | Approve every future confirmation automatically. TUI only. | @@ -560,6 +561,8 @@ account ID and print each ID with any available host and platform metadata. `/devices --remove` accepts exactly one 32-byte hexadecimal statement account ID with an optional `0x` prefix. Interactive removal uses the `[y/N]` approval and describes that only the selected peer is affected. `exec` removal runs directly. +Both modes submit one `Disconnected` message before local cleanup. If submission +fails, the saved pairing, responder, and allowance-renewal target remain intact. Unknown commands, missing required arguments, invalid log levels, invalid products, invalid session names, and arguments passed to no-argument commands diff --git a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh new file mode 100755 index 000000000..e06bf17ef --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" +SCRIPT="$ROOT/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts" +PRODUCT_ID="${PRODUCT_ID:-truapi-playground.dot}" +NETWORK="${TRUAPI_E2E_NETWORK:-paseo-next-v2}" +TIMEOUT_SECONDS="${TRUAPI_E2E_TIMEOUT_SECONDS:-300}" + +[ -x "$BIN" ] || { echo "missing $BIN, run: cargo build -p truapi-host-cli" >&2; exit 2; } +[ -f "$ROOT/js/packages/truapi/src/generated/index.ts" ] || { + echo "missing generated TypeScript client, run: make codegen" >&2 + exit 2 +} +command -v bun >/dev/null || { echo "bun is required" >&2; exit 2; } +command -v tmux >/dev/null || { echo "tmux is required" >&2; exit 2; } + +PAIRING_BASE="$(mktemp -d /tmp/truapi-device-remove-pairing.XXXXXX)" +LOG_DIR="$(mktemp -d /tmp/truapi-device-remove-logs.XXXXXX)" +SIGNER_BASE="${TRUAPI_HOST_BASE_PATH:-$(mktemp -d /tmp/truapi-device-remove-signer.XXXXXX)}" +SIGNER_BASE_OWNED=1 +if [ -n "${TRUAPI_HOST_BASE_PATH:-}" ]; then + SIGNER_BASE_OWNED=0 +fi +PAIRING_LOG="$LOG_DIR/pairing.log" +SIGNING_LOG="$LOG_DIR/signing.log" +TMUX_SESSION="truapi-device-remove-$$" +PAIRING_PID="" +CORE_STORAGE="" + +process_running() { + local process_id="$1" + local state + state="$(ps -p "$process_id" -o stat= 2>/dev/null || true)" + [ -n "$state" ] && [ "${state#Z}" = "$state" ] +} + +stop_process() { + local process_id="$1" + [ -n "$process_id" ] || return 0 + pkill -TERM -P "$process_id" 2>/dev/null || true + kill -TERM "$process_id" 2>/dev/null || true + wait "$process_id" 2>/dev/null || true +} + +capture_signing_host() { + tmux capture-pane -p -J -S - -t "$TMUX_SESSION" >"$SIGNING_LOG" +} + +stop_signing_host() { + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +} + +cleanup() { + local status=$? + if [ "$status" -ne 0 ] && tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + capture_signing_host || true + fi + stop_signing_host + stop_process "$PAIRING_PID" + if [ "$status" -eq 0 ]; then + rm -rf -- "$PAIRING_BASE" "$LOG_DIR" + if [ "$SIGNER_BASE_OWNED" -eq 1 ]; then + rm -rf -- "$SIGNER_BASE" + fi + else + echo "E2E logs preserved at $LOG_DIR" >&2 + echo "Pairing state preserved at $PAIRING_BASE" >&2 + if [ "$SIGNER_BASE_OWNED" -eq 1 ]; then + echo "Signing state preserved at $SIGNER_BASE" >&2 + fi + fi + return "$status" +} +trap cleanup EXIT + +wait_for_pairing_pattern() { + local pattern="$1" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if grep -qE "$pattern" "$PAIRING_LOG"; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before matching $pattern" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for $pattern in $PAIRING_LOG" >&2 + return 1 +} + +wait_for_signing_pattern() { + local pattern="$1" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + echo "signing host exited before matching $pattern" >&2 + return 1 + fi + capture_signing_host + if grep -qE "$pattern" "$SIGNING_LOG"; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for $pattern in signing-host pane" >&2 + return 1 +} + +send_signing_command() { + tmux send-keys -t "$TMUX_SESSION" -l "$1" + tmux send-keys -t "$TMUX_SESSION" Enter +} + +wait_for_persisted_auth_session() { + local current_user_path="$PAIRING_BASE/$NETWORK/pairing-host/current-user" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if [ -s "$current_user_path" ]; then + local current_user + current_user="$(tr -d '\r\n' <"$current_user_path")" + CORE_STORAGE="$PAIRING_BASE/$NETWORK/${current_user}_pairing_host/core-storage.json" + if [ -f "$CORE_STORAGE" ] && grep -qE '"00"[[:space:]]*:' "$CORE_STORAGE"; then + return 0 + fi + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before persisting its auth session" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for the persisted pairing-host auth session" >&2 + return 1 +} + +wait_for_auth_session_clear() { + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if [ -f "$CORE_STORAGE" ] && ! grep -qE '"00"[[:space:]]*:' "$CORE_STORAGE"; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before clearing its persisted auth session" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for AuthSession key 00 to leave $CORE_STORAGE" >&2 + return 1 +} + +TRUAPI_HOST_NO_UPDATE=1 NO_COLOR=1 "$BIN" pairing-host \ + --product-id "$PRODUCT_ID" \ + --network "$NETWORK" \ + --script "$SCRIPT" \ + --base-path "$PAIRING_BASE" \ + --auto-accept >"$PAIRING_LOG" 2>&1 & +PAIRING_PID=$! + +wait_for_pairing_pattern 'polkadotapp://pair\?handshake=[[:xdigit:]]+' +deeplink="$(grep -m1 -oE 'polkadotapp://pair\?handshake=[[:xdigit:]]+' "$PAIRING_LOG")" + +printf -v signing_command '%q ' \ + env -u HOST_CLI_SIGNER_MNEMONIC TRUAPI_HOST_NO_UPDATE=1 NO_COLOR=1 \ + "$BIN" signing-host \ + --network "$NETWORK" \ + --base-path "$SIGNER_BASE" \ + --auto-accept +tmux new-session -d -s "$TMUX_SESSION" -x 240 -y 100 -c "$ROOT" "$signing_command" +tmux set-option -t "$TMUX_SESSION" history-limit 10000 >/dev/null + +wait_for_signing_pattern 'TrUAPI signing host' +send_signing_command "/pair $deeplink" +wait_for_pairing_pattern '^DEVICE_REMOVE_CONNECTED$' +wait_for_persisted_auth_session + +send_signing_command '/devices' +wait_for_signing_pattern 'Paired devices for session' +capture_signing_host +mapfile -t device_ids < <( + sed -nE 's/^.*(0x[[:xdigit:]]{64}) .*/\1/p' "$SIGNING_LOG" | sort -u +) +if [ "${#device_ids[@]}" -ne 1 ]; then + echo "expected exactly one listed paired device, found ${#device_ids[@]}" >&2 + exit 1 +fi + +send_signing_command "/devices --remove ${device_ids[0]}" +wait_for_signing_pattern 'Remove paired device' +tmux send-keys -t "$TMUX_SESSION" y +wait_for_signing_pattern 'Paired device removed' + +wait_for_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' +wait_for_pairing_pattern 'Pairing ended' +wait_for_auth_session_clear + +send_signing_command '/devices' +wait_for_signing_pattern 'No paired devices for session' + +echo "DEVICE_REMOVE_E2E_OK" diff --git a/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts b/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts new file mode 100644 index 000000000..c78ba5a75 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts @@ -0,0 +1,42 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + (login.value !== "Success" && login.value !== "AlreadyConnected") +) { + throw new Error( + `requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`, + ); +} + +const statuses: string[] = []; +await new Promise((resolve, reject) => { + let subscription: { unsubscribe(): void } | undefined; + subscription = truapi.account.connectionStatusSubscribe().subscribe({ + next(status) { + statuses.push(status); + if (status === "Connected") { + console.log("DEVICE_REMOVE_CONNECTED"); + } + if (status === "Disconnected") { + subscription?.unsubscribe(); + resolve(); + } + }, + error(error) { + reject(error); + }, + }); +}); + +const expectedStatuses = ["Connected", "Disconnected"]; +assert( + JSON.stringify(statuses) === JSON.stringify(expectedStatuses), + "unexpected account connection statuses", + statuses, +); +console.log("DEVICE_REMOVE_DISCONNECT_OK"); + +await new Promise(() => {}); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 3afa35a08..1b60d2002 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2330,14 +2330,13 @@ fn mark_current_account_exhausted(session: &SigningHostSession) -> Result<()> { async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { let host = establish_paired_host(session, &deeplink).await?; - let statement_account_id = host.statement_account_id(); let exit = session .runtime .resume_pairing(paired_sso_peer(&host)) .await .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; if exit == ResponderExit::PeerDisconnected && session.profile.is_some() { - remove_paired_host(session, &statement_account_id).await?; + remove_paired_host_locally(session, host).await?; } terminal_ui::output_event(SystemEvent::SigningHostExit { outcome: format!("{exit:?}"), @@ -2354,15 +2353,15 @@ async fn start_deeplink_responder( Ok(()) } -async fn remove_paired_host( - session: &mut SigningHostSession, +fn find_paired_host( + session: &SigningHostSession, statement_account_id: &[u8; 32], ) -> Result { let profile = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")?; - let paired_host = session + session .catalog .paired_hosts(profile)? .into_iter() @@ -2373,14 +2372,43 @@ async fn remove_paired_host( hex::encode(statement_account_id), profile.name ) + }) +} + +async fn disconnect_and_remove_paired_host( + session: &mut SigningHostSession, + statement_account_id: &[u8; 32], +) -> Result { + let paired_host = find_paired_host(session, statement_account_id)?; + session + .runtime + .disconnect_paired_host(paired_sso_peer(&paired_host)) + .await + .map_err(|error| { + anyhow::anyhow!( + "failed to notify paired device before removal: {}", + error.reason + ) })?; + remove_paired_host_locally(session, paired_host).await +} + +async fn remove_paired_host_locally( + session: &mut SigningHostSession, + paired_host: PairedHost, +) -> Result { + let statement_account_id = paired_host.statement_account_id(); + let profile = session + .profile + .as_ref() + .context("paired-device management is unavailable when launched with --mnemonic")?; session .catalog - .remove_paired_host(profile, statement_account_id)?; - session.responders.remove(statement_account_id); + .remove_paired_host(profile, &statement_account_id)?; + session.responders.remove(&statement_account_id); if let Err(error) = session .runtime - .untrack_statement_renewal_account(statement_account_id) + .untrack_statement_renewal_account(&statement_account_id) .await { tracing::warn!( @@ -2578,22 +2606,11 @@ fn paired_device_remove_confirmation( .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")?; - let host = session - .catalog - .paired_hosts(profile)? - .into_iter() - .find(|host| host.statement_account_id() == *statement_account_id) - .with_context(|| { - format!( - "paired device 0x{} does not exist in session {}; use /devices to list paired devices", - hex::encode(statement_account_id), - profile.name - ) - })?; + let host = find_paired_host(session, statement_account_id)?; Ok(( format!("Remove paired device {}", paired_device_label(&host)), format!( - "Statement account 0x{}. This stops its responder and removes its saved pairing from session {}. Other paired devices and the signing identity are unchanged. The remote host must pair again.", + "Statement account 0x{}. This notifies the remote host, then stops its responder and removes its saved pairing from session {}. If notification fails, nothing is removed. Other paired devices and the signing identity are unchanged. The remote host must pair again.", hex::encode(statement_account_id), profile.name ), @@ -3139,7 +3156,7 @@ async fn signing_interactive_loop( ui.system("Paired-device removal cancelled"); continue; } - match remove_paired_host(session, &statement_account_id).await { + match disconnect_and_remove_paired_host(session, &statement_account_id).await { Ok(host) => ui.success( "Paired device removed", Some(format!( @@ -3444,7 +3461,7 @@ async fn execute_non_interactive_command( .context("paired-device management is unavailable when launched with --mnemonic")? .name .clone(); - remove_paired_host(session, &statement_account_id).await?; + disconnect_and_remove_paired_host(session, &statement_account_id).await?; println!( "Removed paired device 0x{} from session {}", hex::encode(statement_account_id), diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 788a61b9e..125e7a766 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -805,7 +805,7 @@ pub const HELP_TEXT: &str = "\ /pair read a pairing QR image file /pair answer a Polkadot Mobile pairing URL /devices list paired devices for the active session -/devices --remove remove one paired device by statement account ID +/devices --remove disconnect and remove one paired device by statement account ID /approval show the current confirmation approval mode /approval manual prompt for every future confirmation /approval automatic approve every future confirmation automatically diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index 7c741b13c..703478a99 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -232,7 +232,7 @@ fn exec_clear_all_removes_every_session_for_the_network() { } #[test] -fn exec_devices_lists_and_removes_exactly_one_paired_device() { +fn exec_device_removal_preserves_pairings_when_the_peer_cannot_be_notified() { let temporary = tempfile::tempdir().expect("create temporary session root"); let profile = temporary.path().join("paseo-next-v2/alice_signing_host"); std::fs::create_dir_all(&profile).expect("create signing-host profile"); @@ -282,11 +282,8 @@ fn exec_devices_lists_and_removes_exactly_one_paired_device() { .stdin(Stdio::null()) .output() .expect("remove paired device"); - assert!(removed.status.success()); - assert!(String::from_utf8_lossy(&removed.stdout).contains(&format!( - "Removed paired device 0x{} from session alice", - hex::encode([1; 32]) - ))); + assert_eq!(removed.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&removed.stderr).contains("no active local session")); let stored: serde_json::Value = serde_json::from_slice( &std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), @@ -294,12 +291,20 @@ fn exec_devices_lists_and_removes_exactly_one_paired_device() { .expect("decode paired hosts"); assert_eq!( stored["paired_hosts"], - serde_json::json!([{ - "version": 1, - "statement_account_id": vec![2_u8; 32], - "encryption_public_key": vec![22_u8; 32], - "host_name": "Second" - }]) + serde_json::json!([ + { + "version": 1, + "statement_account_id": vec![1_u8; 32], + "encryption_public_key": vec![11_u8; 32], + "host_name": "First" + }, + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ]) ); } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 7291ecad7..f35fb62c6 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -34,7 +34,8 @@ use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequ use crate::runtime::{ ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, - SigningHostRole, answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, + SigningHostRole, answer_remote_message, disconnect_paired_host, establish_pairing, + respond_to_pairing, resume_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -640,6 +641,17 @@ impl SigningHostRuntime { .map_err(|reason| v01::GenericError { reason }) } + /// Notify a paired host that this signing host is ending their SSO session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_paired_host"))] + pub async fn disconnect_paired_host( + &self, + peer: PairedSsoPeer, + ) -> Result<(), v01::GenericError> { + disconnect_paired_host(self.services.clone(), self.signing_host.clone(), peer) + .await + .map_err(|reason| v01::GenericError { reason }) + } + /// Answer one decrypted SSO remote message with this signing host. /// /// Session control stays with the caller: `Disconnected` is reported as an @@ -1247,6 +1259,14 @@ impl Transport for SinkTransport { mod tests { use super::*; use crate::frame::{Payload, ProtocolMessage, subscription_ids}; + use crate::host_logic::product_account::derive_identity_keypair; + use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, decode_incoming_sso_request, v1, + }; + use crate::host_logic::sso::pairing::{ + PairingBootstrap, derive_x25519_keypair_from_entropy, establish_sso_session_info, + x25519_public_key, + }; use crate::test_support::{StubPlatform, runtime_config, test_spawner}; use parity_scale_codec::Encode; use std::sync::atomic::Ordering; @@ -1265,6 +1285,27 @@ mod tests { } } + fn activated_signing_runtime(platform: Arc) -> SigningHostRuntime { + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Unknown, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let runtime = SigningHostRuntime::new(platform, config, test_spawner()); + futures::executor::block_on(runtime.activate_local_session(vec![0xab; 32])) + .expect("activation succeeds"); + runtime + } + fn assert_send(_: T) {} fn assert_send_sync() {} @@ -1631,4 +1672,121 @@ mod tests { assert_eq!(payload.responding_to, "m3"); assert!(payload.product_public_key.is_ok()); } + + #[test] + fn disconnect_paired_host_submits_one_disconnected_message_to_the_selected_peer() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], + ..Default::default() + }); + let runtime = activated_signing_runtime(platform.clone()); + let peer_encryption_secret = [0x42; 32]; + let peer = PairedSsoPeer { + statement_account_id: [0x31; 32], + encryption_public_key: x25519_public_key(peer_encryption_secret), + }; + let identity = derive_identity_keypair(&[0xab; 32]).expect("identity derivation succeeds"); + let (_, responder_encryption_public_key) = + derive_x25519_keypair_from_entropy(&[0xab; 32], b"sso"); + let pairing_session = establish_sso_session_info( + &PairingBootstrap { + deeplink: String::new(), + topic: [0; 32], + statement_store_public_key: peer.statement_account_id, + statement_store_secret: [0; 64], + encryption_public_key: peer.encryption_public_key, + encryption_secret_key: peer_encryption_secret, + }, + identity.public.to_bytes(), + responder_encryption_public_key, + ) + .expect("pairing session derivation succeeds"); + let unrelated_peer_encryption_secret = [0x43; 32]; + let unrelated_pairing_session = establish_sso_session_info( + &PairingBootstrap { + deeplink: String::new(), + topic: [0; 32], + statement_store_public_key: [0x32; 32], + statement_store_secret: [0; 64], + encryption_public_key: x25519_public_key(unrelated_peer_encryption_secret), + encryption_secret_key: unrelated_peer_encryption_secret, + }, + identity.public.to_bytes(), + responder_encryption_public_key, + ) + .expect("unrelated pairing session derivation succeeds"); + + futures::executor::block_on(runtime.disconnect_paired_host(peer)) + .expect("disconnect submission succeeds"); + + let submits = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .filter_map(|request| { + let value: serde_json::Value = serde_json::from_str(request).ok()?; + (value["method"] == "statement_submit").then_some(value) + }) + .collect::>(); + let statement_hex = submits[0]["params"][0] + .as_str() + .expect("statement submit carries encoded bytes"); + let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)) + .expect("submitted statement is hex"); + let incoming = decode_incoming_sso_request(&pairing_session, &statement) + .expect("selected peer decrypts the statement") + .expect("submitted statement is an SSO request"); + let unrelated_error = decode_incoming_sso_request(&unrelated_pairing_session, &statement) + .expect_err("unrelated peer cannot decrypt the statement envelope"); + let message_id = incoming.messages[0].message_id.clone(); + + assert_eq!( + ( + submits.len(), + incoming.request_id, + incoming.messages, + unrelated_error.request_id, + unrelated_error + .reason + .starts_with("failed to decrypt SSO statement data"), + ), + ( + 1, + message_id.clone(), + vec![RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + None, + true, + ) + ); + } + + #[test] + fn disconnect_paired_host_propagates_submission_failure() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"reason":"badProof","status":"rejected"}}"# + .to_string(), + ], + ..Default::default() + }); + let runtime = activated_signing_runtime(platform); + let peer = PairedSsoPeer { + statement_account_id: [0x31; 32], + encryption_public_key: x25519_public_key([0x42; 32]), + }; + + let error = futures::executor::block_on(runtime.disconnect_paired_host(peer)) + .expect_err("disconnect submission failure is returned to the caller"); + + assert_eq!( + error.reason, + r#"statement_submit not accepted: {"reason":"badProof","status":"rejected"}"# + ); + } } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fbf538c8e..f13e352a3 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -68,8 +68,8 @@ pub(crate) use services::RuntimeServices; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; pub(crate) use signing_host::{ - LocalActivation, SigningHost as SigningHostRole, answer_remote_message, establish_pairing, - respond_to_pairing, resume_pairing, + LocalActivation, SigningHost as SigningHostRole, answer_remote_message, disconnect_paired_host, + establish_pairing, respond_to_pairing, resume_pairing, }; pub use signing_host::{PairedSsoPeer, ResponderExit}; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index d2f0c82d2..4c7a3c4d6 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -30,7 +30,8 @@ pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::{PairedSsoPeer, ResponderExit}; pub(crate) use sso_responder::{ - answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, + answer_remote_message, disconnect_paired_host, establish_pairing, respond_to_pairing, + resume_pairing, }; use super::authority::{ diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index c5f3908dc..9ee553dd0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -59,7 +59,7 @@ use crate::runtime::authority::{ SignRawAuthorityRequest, }; use crate::runtime::services::RuntimeServices; -use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::sso_remote::{fresh_statement_expiry, sso_message_id}; #[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::StatementAllowanceError; use crate::runtime::statement_store_rpc; @@ -345,6 +345,33 @@ pub(crate) async fn resume_pairing( .await } +/// Notify a paired host that this signing host is ending their SSO session. +pub(crate) async fn disconnect_paired_host( + services: Arc, + signing_host: Arc, + peer: PairedSsoPeer, +) -> Result<(), String> { + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + let session = responder_session(&entropy, peer)?; + let message_id = sso_message_id(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + &session, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + services + .statement_store + .submit_sso(statement, "sso-responder disconnect") + .await +} + fn responder_session(entropy: &[u8], peer: PairedSsoPeer) -> Result { let (identity, _) = derive_responder_identity(entropy) .map_err(|err| format!("responder identity derivation failed: {err}"))?; From 13ed77ba346ffe9dc112a925a48906fd420629e4 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 4 Sep 2026 13:58:41 +0200 Subject: [PATCH 2/6] test(host-cli): await new disconnect event --- .../e2e/device-removal-disconnect.sh | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh index e06bf17ef..a0ff3c35c 100755 --- a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -92,6 +92,26 @@ wait_for_pairing_pattern() { return 1 } +wait_for_new_pairing_pattern() { + local pattern="$1" + local previous_count="$2" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + local current_count + current_count="$(grep -cE "$pattern" "$PAIRING_LOG" || true)" + if [ "$current_count" -gt "$previous_count" ]; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before another match for $pattern" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for another match for $pattern in $PAIRING_LOG" >&2 + return 1 +} + wait_for_signing_pattern() { local pattern="$1" local deadline=$((SECONDS + TIMEOUT_SECONDS)) @@ -177,6 +197,7 @@ wait_for_signing_pattern 'TrUAPI signing host' send_signing_command "/pair $deeplink" wait_for_pairing_pattern '^DEVICE_REMOVE_CONNECTED$' wait_for_persisted_auth_session +pairing_ended_before_removal="$(grep -c 'Pairing ended' "$PAIRING_LOG" || true)" send_signing_command '/devices' wait_for_signing_pattern 'Paired devices for session' @@ -195,7 +216,7 @@ tmux send-keys -t "$TMUX_SESSION" y wait_for_signing_pattern 'Paired device removed' wait_for_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' -wait_for_pairing_pattern 'Pairing ended' +wait_for_new_pairing_pattern 'Pairing ended' "$pairing_ended_before_removal" wait_for_auth_session_clear send_signing_command '/devices' From 2104178b8ed8bd05df3aaf43e12ba5b8b765a61d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 4 Sep 2026 14:18:12 +0200 Subject: [PATCH 3/6] test(host-cli): bind disconnect to removal --- .../truapi-host-cli/e2e/device-removal-disconnect.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh index a0ff3c35c..92d7719de 100755 --- a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -197,7 +197,6 @@ wait_for_signing_pattern 'TrUAPI signing host' send_signing_command "/pair $deeplink" wait_for_pairing_pattern '^DEVICE_REMOVE_CONNECTED$' wait_for_persisted_auth_session -pairing_ended_before_removal="$(grep -c 'Pairing ended' "$PAIRING_LOG" || true)" send_signing_command '/devices' wait_for_signing_pattern 'Paired devices for session' @@ -212,10 +211,16 @@ fi send_signing_command "/devices --remove ${device_ids[0]}" wait_for_signing_pattern 'Remove paired device' +disconnect_marker_before_removal="$(grep -cE '^DEVICE_REMOVE_DISCONNECT_OK$' "$PAIRING_LOG" || true)" +pairing_ended_before_removal="$(grep -c 'Pairing ended' "$PAIRING_LOG" || true)" +if [ "$disconnect_marker_before_removal" -ne 0 ]; then + echo "pairing host disconnected before removal was confirmed" >&2 + exit 1 +fi tmux send-keys -t "$TMUX_SESSION" y wait_for_signing_pattern 'Paired device removed' -wait_for_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' +wait_for_new_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' "$disconnect_marker_before_removal" wait_for_new_pairing_pattern 'Pairing ended' "$pairing_ended_before_removal" wait_for_auth_session_clear From c689dbc4161456f3ea5374a122299c2287bbb5a6 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Fri, 4 Sep 2026 13:19:42 -0400 Subject: [PATCH 4/6] docs(host-cli): name the removal test for its trigger and record the ordering asymmetry The preservation test drives the no-local-session failure rather than a notification rejection, so it is named for that and points at the two places the other failures are covered. The notify-then-remove order fails asymmetrically: a failed notification keeps everything, while a failed local removal after a successful notification leaves the peer disconnected and the device still listed. Stating which direction is preferred keeps the order from reading as an accident. --- rust/crates/truapi-host-cli/src/main.rs | 10 ++++++++++ rust/crates/truapi-host-cli/tests/signing_host_cli.rs | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 6ab2bff45..7ddff2b97 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2460,6 +2460,16 @@ fn find_paired_host( }) } +/// Notify the peer, then drop the local pairing. +/// +/// The order is deliberate and it is not symmetric. A failed notification leaves +/// everything in place, so the operator can retry against a device that is still +/// listed. A notification that succeeds and is then followed by a failed local +/// removal leaves the peer believing the session is over while this host still +/// lists the device and runs its responder; the error surfaces to the operator, +/// and a retry re-notifies a peer that has already been told. That window is a +/// local file write wide, and it is the cheaper direction to fail in than telling +/// the operator a device is gone while the peer still holds a live session. async fn disconnect_and_remove_paired_host( session: &mut SigningHostSession, statement_account_id: &[u8; 32], diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index 703478a99..cd5b24844 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -231,8 +231,15 @@ fn exec_clear_all_removes_every_session_for_the_network() { assert!(!output.stdout.contains(&0x1b)); } +/// Removal is notify-first, so anything that stops the notification leaves every +/// pairing in place. This drives the cheapest such failure: no local session, so +/// `disconnect_paired_host` fails at `root_entropy` before it reaches the +/// statement store. A rejection from the store itself takes the same branch and +/// is covered by `disconnect_paired_host_propagates_submission_failure` in +/// `truapi-server`; the end-to-end path needs two live hosts and lives in +/// `e2e/device-removal-disconnect.sh`. #[test] -fn exec_device_removal_preserves_pairings_when_the_peer_cannot_be_notified() { +fn exec_device_removal_preserves_pairings_when_the_local_session_is_inactive() { let temporary = tempfile::tempdir().expect("create temporary session root"); let profile = temporary.path().join("paseo-next-v2/alice_signing_host"); std::fs::create_dir_all(&profile).expect("create signing-host profile"); From e7ffbfa8e857f24d6e64833df2af4fe7e09f09ee Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sat, 5 Sep 2026 07:47:38 +0200 Subject: [PATCH 5/6] fix(host-cli): add forced device removal Keep normal removal notify-first for iOS parity, while allowing operators to drop local responder state when disconnect submission fails. --- rust/crates/truapi-host-cli/README.md | 8 +- rust/crates/truapi-host-cli/SPEC.md | 17 ++- rust/crates/truapi-host-cli/src/main.rs | 117 ++++++++++++------ .../truapi-host-cli/src/signing_shell.rs | 75 +++++++++-- .../crates/truapi-host-cli/src/terminal_ui.rs | 59 +++++++-- .../truapi-host-cli/tests/signing_host_cli.rs | 109 +++++++++++----- 6 files changed, 291 insertions(+), 94 deletions(-) diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 06ef92093..5a66e9d81 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -213,6 +213,7 @@ Commands always start with `/`: | `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | | `/devices` or `/devices --list` | List every paired device saved for the active signing-host session. | | `/devices --remove ` | Disconnect and remove one paired device by its 32-byte statement account ID. | +| `/devices --remove --force` | Attempt to disconnect one paired device, then remove its local pairing even if notification fails. | | `/approval` | Show whether signing-host confirmations are manual or automatic. | | `/approval manual` | Prompt for every future signing-host confirmation. | | `/approval automatic` | Approve every future signing-host confirmation automatically. | @@ -388,7 +389,11 @@ prompt. Removal first submits `Disconnected` to the selected remote host. Only after the statement is accepted does it stop that responder, remove the saved pairing, and stop its allowance renewal. A submission failure preserves all local pairing state. The other saved pairings and the signing identity are -unchanged. +unchanged. For recovery when notification cannot be submitted, append +`--force`. The command still attempts notification first, but warns and +continues with local cleanup if that attempt fails. The remote host may continue +to show stale connected state, but it cannot reach a responder on this signing +host. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` @@ -423,6 +428,7 @@ truapi-host signing-host exec '/pair polkadotapp://pair?handshake=...' truapi-host signing-host --session alice.01 exec '/devices' truapi-host signing-host --session alice.01 exec '/devices --list' truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --force' ``` `exec` does not enable raw mode or emit terminal controls. Command results go diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 860c2366d..08b2de788 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -422,6 +422,7 @@ For example: truapi-host signing-host --session alice.01 exec '/devices' truapi-host signing-host --session alice.01 exec '/devices --list' truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --force' ``` `exec '/script'` needs a TTY because it opens an editor. In non-TTY execution, @@ -432,7 +433,8 @@ use `exec '/script '` instead. `/copy` and `/approval` are unavailable. saved pairings without starting their responders. `exec '/devices --remove '` is an explicit removal and does not ask for another confirmation. It submits `Disconnected` directly and removes local state only -after the statement is accepted. +after the statement is accepted. Appending `--force` still attempts that +submission, but warns and continues with local cleanup if it fails. ### 6.5 `--serve` @@ -538,6 +540,7 @@ Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. | `/devices` | no | yes | List paired devices saved for the active managed session. | | `/devices --list` | no | yes | List paired devices saved for the active managed session. | | `/devices --remove ` | no | yes | Disconnect and remove one paired device by its 32-byte statement account ID. | +| `/devices --remove --force` | no | yes | Attempt to disconnect one paired device, then remove its local pairing even if notification fails. | | `/approval` | no | yes | Print the current manual or automatic approval mode. TUI only. | | `/approval manual` | no | yes | Prompt for every future confirmation. TUI only. | | `/approval automatic` | no | yes | Approve every future confirmation automatically. TUI only. | @@ -563,10 +566,14 @@ quoted or escaped `/pair` argument is treated as an image path. `/devices` and `/devices --list` are equivalent. They sort peers by statement account ID and print each ID with any available host and platform metadata. `/devices --remove` accepts exactly one 32-byte hexadecimal statement account ID -with an optional `0x` prefix. Interactive removal uses the `[y/N]` approval and -describes that only the selected peer is affected. `exec` removal runs directly. -Both modes submit one `Disconnected` message before local cleanup. If submission -fails, the saved pairing, responder, and allowance-renewal target remain intact. +with an optional `0x` prefix and an optional trailing `--force`. Interactive +removal uses the `[y/N]` approval and describes that only the selected peer is +affected. `exec` removal runs directly. Both modes submit one `Disconnected` +message before local cleanup. If submission fails, ordinary removal preserves +the saved pairing, responder, and allowance-renewal target. Forced removal emits +an unfiltered warning and continues with local cleanup, so the remote host may +continue to show stale connected state, but it cannot reach a responder on this +signing host. Unknown commands, missing required arguments, invalid log levels, invalid products, invalid session names, and arguments passed to no-argument commands diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 7ddff2b97..1fef79b76 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2460,32 +2460,48 @@ fn find_paired_host( }) } -/// Notify the peer, then drop the local pairing. -/// -/// The order is deliberate and it is not symmetric. A failed notification leaves -/// everything in place, so the operator can retry against a device that is still -/// listed. A notification that succeeds and is then followed by a failed local -/// removal leaves the peer believing the session is over while this host still -/// lists the device and runs its responder; the error surfaces to the operator, -/// and a retry re-notifies a peer that has already been told. That window is a -/// local file write wide, and it is the cheaper direction to fail in than telling -/// the operator a device is gone while the peer still holds a live session. +struct PairedHostRemoval { + paired_host: PairedHost, + notification_failure: Option, +} + +/// Submit the disconnect first so ordinary removal never deletes an unnotified +/// pairing. A later cleanup failure remains retryable even though the peer may +/// already consider the session closed. async fn disconnect_and_remove_paired_host( session: &mut SigningHostSession, statement_account_id: &[u8; 32], -) -> Result { + force: bool, +) -> Result { let paired_host = find_paired_host(session, statement_account_id)?; - session + let notification_failure = match session .runtime .disconnect_paired_host(paired_sso_peer(&paired_host)) .await - .map_err(|error| { - anyhow::anyhow!( + { + Ok(()) => None, + Err(error) if force => Some(error.reason), + Err(error) => { + bail!( "failed to notify paired device before removal: {}", error.reason ) - })?; - remove_paired_host_locally(session, paired_host).await + } + }; + let paired_host = remove_paired_host_locally(session, paired_host).await?; + Ok(PairedHostRemoval { + paired_host, + notification_failure, + }) +} + +fn forced_removal_warning(reason: &str) -> (&'static str, String) { + ( + "Paired device removed without notification", + format!( + "Notification failed: {reason}. Forced local removal completed. The remote host may still show stale connected state, but it cannot reach a responder on this signing host." + ), + ) } async fn remove_paired_host_locally( @@ -2696,16 +2712,22 @@ fn format_paired_device_list(session_name: &str, mut paired_hosts: Vec Result<(String, String)> { let profile = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")?; let host = find_paired_host(session, statement_account_id)?; + let notification_failure = if force { + "If notification fails, local removal still continues. The remote host may show stale connected state, but it cannot reach this responder after removal." + } else { + "If notification fails, nothing is removed." + }; Ok(( format!("Remove paired device {}", paired_device_label(&host)), format!( - "Statement account 0x{}. This notifies the remote host, then stops its responder and removes its saved pairing from session {}. If notification fails, nothing is removed. Other paired devices and the signing identity are unchanged. The remote host must pair again.", + "Statement account 0x{}. This notifies the remote host, then stops its responder and removes its saved pairing from session {}. {notification_failure} Other paired devices and the signing identity are unchanged. The remote host must pair again.", hex::encode(statement_account_id), profile.name ), @@ -3230,15 +3252,21 @@ async fn signing_interactive_loop( Ok(devices) => ui.system(devices), Err(error) => ui.error(format!("failed to list paired devices: {error}")), }, - ShellCommand::Devices(DeviceCommand::Remove(statement_account_id)) => { - let (action, detail) = - match paired_device_remove_confirmation(session, &statement_account_id) { - Ok(confirmation) => confirmation, - Err(error) => { - ui.error(error.to_string()); - continue; - } - }; + ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id, + force, + }) => { + let (action, detail) = match paired_device_remove_confirmation( + session, + &statement_account_id, + force, + ) { + Ok(confirmation) => confirmation, + Err(error) => { + ui.error(error.to_string()); + continue; + } + }; let handle = ui.handle(); let approved = match ui .drive(input.clone(), handle.confirm(action, detail)) @@ -3251,15 +3279,22 @@ async fn signing_interactive_loop( ui.system("Paired-device removal cancelled"); continue; } - match disconnect_and_remove_paired_host(session, &statement_account_id).await { - Ok(host) => ui.success( - "Paired device removed", - Some(format!( - "{}\nStatement account 0x{}", - paired_device_label(&host), - hex::encode(statement_account_id) - )), - ), + match disconnect_and_remove_paired_host(session, &statement_account_id, force).await + { + Ok(removal) => { + if let Some(reason) = removal.notification_failure { + let (title, detail) = forced_removal_warning(&reason); + ui.warning(title, Some(detail)); + } + ui.success( + "Paired device removed", + Some(format!( + "{}\nStatement account 0x{}", + paired_device_label(&removal.paired_host), + hex::encode(statement_account_id) + )), + ); + } Err(error) => ui.error(error.to_string()), } } @@ -3549,14 +3584,22 @@ async fn execute_non_interactive_command( ShellCommand::Devices(DeviceCommand::List) => { println!("{}", paired_device_list(session)?); } - ShellCommand::Devices(DeviceCommand::Remove(statement_account_id)) => { + ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id, + force, + }) => { let profile_name = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")? .name .clone(); - disconnect_and_remove_paired_host(session, &statement_account_id).await?; + let removal = + disconnect_and_remove_paired_host(session, &statement_account_id, force).await?; + if let Some(reason) = removal.notification_failure { + let (title, detail) = forced_removal_warning(&reason); + terminal_ui::output_warning(title, Some(detail)); + } println!( "Removed paired device 0x{} from session {}", hex::encode(statement_account_id), diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 125e7a766..08ccf5ff9 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -25,7 +25,10 @@ pub enum DeviceCommand { /// List paired devices for the active managed session. List, /// Remove the device with this statement account ID. - Remove([u8; 32]), + Remove { + statement_account_id: [u8; 32], + force: bool, + }, } /// Operation selected through `/approval`. @@ -200,14 +203,23 @@ pub fn parse_command(input: &str) -> Result { let arguments = shlex::split(argument).ok_or_else(|| "invalid /devices quoting".to_string())?; if arguments.first().is_some_and(|value| value == "--remove") { - if arguments.len() != 2 { - return Err("usage: /devices --remove ".to_string()); - } - return Ok(ShellCommand::Devices(DeviceCommand::Remove( - parse_statement_account_id(&arguments[1])?, - ))); + let (statement_account_id, force) = match arguments.as_slice() { + [_, statement_account_id] => (statement_account_id, false), + [_, statement_account_id, force] if force == "--force" => { + (statement_account_id, true) + } + _ => { + return Err( + "usage: /devices --remove [--force]".to_string() + ); + } + }; + return Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: parse_statement_account_id(statement_account_id)?, + force, + })); } - Err("usage: /devices [--list | --remove ]".to_string()) + Err("usage: /devices [--list | --remove [--force]]".to_string()) } "/approval" => match argument { "" => Ok(ShellCommand::Approval(ApprovalCommand::Current)), @@ -394,6 +406,21 @@ fn completions_for_scope( if scope == CommandScope::SigningHost && let Some(prefix) = input.strip_prefix("/devices ") { + if let Some((statement_account_id, force_prefix)) = + prefix.strip_prefix("--remove ").and_then(|value| { + value + .split_once(char::is_whitespace) + .map(|(statement_account_id, force_prefix)| { + (statement_account_id, force_prefix.trim_start()) + }) + }) + { + return fixed_argument_completions( + &format!("/devices --remove {statement_account_id}"), + force_prefix, + &[("--force", "remove locally if notification fails")], + ); + } return fixed_argument_completions( "/devices", prefix, @@ -806,6 +833,7 @@ pub const HELP_TEXT: &str = "\ /pair answer a Polkadot Mobile pairing URL /devices list paired devices for the active session /devices --remove disconnect and remove one paired device by statement account ID +/devices --remove --force remove locally even if notification fails /approval show the current confirmation approval mode /approval manual prompt for every future confirmation /approval automatic approve every future confirmation automatically @@ -902,11 +930,24 @@ mod tests { ); assert_eq!( parse_command(&format!("/devices --remove 0x{DEVICE_ID}")), - Ok(ShellCommand::Devices(DeviceCommand::Remove([1; 32]))) + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: false, + })) ); assert_eq!( parse_command(&format!("/devices --remove 0X{DEVICE_ID}")), - Ok(ShellCommand::Devices(DeviceCommand::Remove([1; 32]))) + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: false, + })) + ); + assert_eq!( + parse_command(&format!("/devices --remove 0x{DEVICE_ID} --force")), + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: true, + })) ); assert_eq!( parse_command("/session"), @@ -975,6 +1016,9 @@ mod tests { assert!(parse_command("/devices --remove").is_err()); assert!(parse_command("/devices --remove not-an-account").is_err()); assert!(parse_command(&format!("/devices --remove {DEVICE_ID} extra")).is_err()); + assert!(parse_command(&format!("/devices --remove --force {DEVICE_ID}")).is_err()); + assert!(parse_command(&format!("/devices --remove {DEVICE_ID} --force --force")).is_err()); + assert!(parse_command("/devices --force").is_err()); assert!(parse_command("/devices --unknown").is_err()); assert!(parse_command("/log noisy").is_err()); assert!(parse_command("/product example.com").is_err()); @@ -1125,6 +1169,17 @@ mod tests { }, ] ); + assert_eq!( + completions_for_scope( + &format!("/devices --remove {DEVICE_ID} --f"), + &[], + CommandScope::SigningHost + ), + vec![Completion { + value: format!("/devices --remove {DEVICE_ID} --force"), + description: "remove locally if notification fails", + }] + ); assert!(completions_for_scope("/devices", &[], CommandScope::PairingHost).is_empty()); } diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 3093a4973..7cf28a601 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -355,6 +355,20 @@ pub fn output_success(title: impl Into, detail: Option) { } } +/// Emit a warning through the active transcript or standard error. +pub fn output_warning(title: impl Into, detail: Option) { + let title = title.into(); + if !send_to_active(UiEvent::Notice { + tone: NoticeTone::Warning, + title: title.clone(), + detail: detail.clone(), + }) { + let mut app = App::new_pairing(String::new(), String::new(), "info".to_string()); + app.notice(NoticeTone::Warning, title, detail); + write_human_stderr(&app.transcript_text()); + } +} + fn write_human_stdout(text: &str) { let styled = styled_output(io::stdout().is_terminal()); let mut stdout = io::stdout().lock(); @@ -723,6 +737,11 @@ impl ActiveTerminalUi { self.app.notice(NoticeTone::Success, text.into(), detail); } + /// Record an immediate warning. + pub fn warning(&mut self, text: impl Into, detail: Option) { + self.app.notice(NoticeTone::Warning, text.into(), detail); + } + /// Record a typed lifecycle event. pub fn event(&mut self, event: SystemEvent) { self.app.handle_system_event(event); @@ -3084,6 +3103,19 @@ mod tests { ) } + fn test_active_ui() -> ActiveTerminalUi { + let (sender, receiver) = mpsc::unbounded_channel(); + ActiveTerminalUi { + terminal: None, + events: None, + receiver, + sender, + app: test_app(), + clipboard: None, + copy_next_pairing_deeplink: false, + } + } + #[test] fn approval_temporarily_replaces_and_then_restores_command_draft() { let mut app = test_app(); @@ -3390,16 +3422,7 @@ mod tests { #[test] fn interactive_error_preserves_backend_cause_chain() { - let (sender, receiver) = mpsc::unbounded_channel(); - let mut ui = ActiveTerminalUi { - terminal: None, - events: None, - receiver, - sender, - app: test_app(), - clipboard: None, - copy_next_pairing_deeplink: false, - }; + let mut ui = test_active_ui(); let error = anyhow::anyhow!("backend supplied explanation") .context("username registration failed (503 Service Unavailable)") .context("attest account auto-1"); @@ -3412,6 +3435,22 @@ mod tests { ); } + #[test] + fn immediate_warning_precedes_following_success() { + let mut ui = test_active_ui(); + + ui.warning( + "Paired device removed without notification", + Some("notification failed".to_string()), + ); + ui.success("Paired device removed", None); + + assert_eq!( + ui.app.transcript_text(), + "! Paired device removed without notification\n notification failed\n✓ Paired device removed" + ); + } + #[test] fn script_streams_group_lines_and_preserve_blank_lines() { let mut app = test_app(); diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index cd5b24844..2e2f419eb 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -1,11 +1,40 @@ //! Process-boundary smoke tests for signing-host invocation modes. +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; fn command() -> Command { Command::new(env!("CARGO_BIN_EXE_truapi-host")) } +fn seed_two_paired_hosts(base_path: &Path) -> PathBuf { + let profile = base_path.join("paseo-next-v2/alice_signing_host"); + std::fs::create_dir_all(&profile).expect("create signing-host profile"); + std::fs::write( + profile.join("paired-hosts.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "paired_hosts": [ + { + "version": 1, + "statement_account_id": vec![1_u8; 32], + "encryption_public_key": vec![11_u8; 32], + "host_name": "First" + }, + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ] + })) + .expect("encode paired hosts"), + ) + .expect("seed paired hosts"); + profile +} + #[test] fn interactive_mode_rejects_non_tty_stdio_with_usage_exit() { let output = command() @@ -45,6 +74,7 @@ fn exec_help_is_plain_and_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("/product")); assert!(String::from_utf8_lossy(&output.stdout).contains("/session")); assert!(String::from_utf8_lossy(&output.stdout).contains("/devices")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/devices --remove --force")); assert!(String::from_utf8_lossy(&output.stdout).contains("/approval automatic")); assert!(String::from_utf8_lossy(&output.stdout).contains("/session --clear-all")); #[cfg(unix)] @@ -231,40 +261,10 @@ fn exec_clear_all_removes_every_session_for_the_network() { assert!(!output.stdout.contains(&0x1b)); } -/// Removal is notify-first, so anything that stops the notification leaves every -/// pairing in place. This drives the cheapest such failure: no local session, so -/// `disconnect_paired_host` fails at `root_entropy` before it reaches the -/// statement store. A rejection from the store itself takes the same branch and -/// is covered by `disconnect_paired_host_propagates_submission_failure` in -/// `truapi-server`; the end-to-end path needs two live hosts and lives in -/// `e2e/device-removal-disconnect.sh`. #[test] fn exec_device_removal_preserves_pairings_when_the_local_session_is_inactive() { let temporary = tempfile::tempdir().expect("create temporary session root"); - let profile = temporary.path().join("paseo-next-v2/alice_signing_host"); - std::fs::create_dir_all(&profile).expect("create signing-host profile"); - std::fs::write( - profile.join("paired-hosts.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, - "paired_hosts": [ - { - "version": 1, - "statement_account_id": vec![1_u8; 32], - "encryption_public_key": vec![11_u8; 32], - "host_name": "First" - }, - { - "version": 1, - "statement_account_id": vec![2_u8; 32], - "encryption_public_key": vec![22_u8; 32], - "host_name": "Second" - } - ] - })) - .expect("encode paired hosts"), - ) - .expect("seed paired hosts"); + let profile = seed_two_paired_hosts(temporary.path()); let listed = command() .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) @@ -315,6 +315,53 @@ fn exec_device_removal_preserves_pairings_when_the_local_session_is_inactive() { ); } +#[test] +fn exec_force_device_removal_removes_exactly_one_pairing_when_notification_fails() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let profile = seed_two_paired_hosts(temporary.path()); + + let remove_command = format!("/devices --remove 0x{} --force", hex::encode([1_u8; 32])); + let removed = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(temporary.path()) + .args(["--session", "alice", "exec", &remove_command]) + .stdin(Stdio::null()) + .output() + .expect("force remove paired device"); + + assert!(removed.status.success()); + let expected = format!( + "Removed paired device 0x{} from session alice", + hex::encode([1_u8; 32]) + ); + assert_eq!( + String::from_utf8_lossy(&removed.stdout).lines().last(), + Some(expected.as_str()) + ); + let stderr = String::from_utf8_lossy(&removed.stderr); + assert!(stderr.contains("Paired device removed without notification")); + assert!(stderr.contains("no active local session")); + assert!(stderr.contains("Forced local removal completed")); + assert!(stderr.contains("cannot reach a responder on this signing host")); + + let stored: serde_json::Value = serde_json::from_slice( + &std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), + ) + .expect("decode paired hosts"); + assert_eq!( + stored["paired_hosts"], + serde_json::json!([ + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ]) + ); +} + #[test] fn default_session_is_not_user_selectable() { let temporary = tempfile::tempdir().expect("create temporary session root"); From 32998ff98ca097795bd2c1656e84be1616dd4f7b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 11 Sep 2026 13:13:12 +0200 Subject: [PATCH 6/6] fix(host-cli): keep device removal responsive --- rust/crates/truapi-host-cli/README.md | 23 ++++++------ rust/crates/truapi-host-cli/SPEC.md | 8 +++-- .../e2e/device-removal-disconnect.sh | 5 ++- rust/crates/truapi-host-cli/src/main.rs | 36 +++++++++++-------- .../truapi-host-cli/src/signing_shell.rs | 2 +- 5 files changed, 44 insertions(+), 30 deletions(-) diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 1870ef9dd..fa9199838 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -401,14 +401,15 @@ order with available host and platform metadata. Interactive `/devices --remove ` asks for confirmation. The same command through `exec` is an explicit one-shot removal and runs without another prompt. Removal first submits `Disconnected` to the selected remote host. Only -after the statement is accepted does it stop that responder, remove the saved -pairing, and stop its allowance renewal. A submission failure preserves all +after the statement store accepts it does it stop that responder, remove the +saved pairing, and stop its allowance renewal. A submission failure preserves all local pairing state. The other saved pairings and the signing identity are unchanged. For recovery when notification cannot be submitted, append `--force`. The command still attempts notification first, but warns and -continues with local cleanup if that attempt fails. The remote host may continue -to show stale connected state, but it cannot reach a responder on this signing -host. +continues with local cleanup if that attempt fails or times out after 30 seconds. +Submission does not wait for the remote host to acknowledge receipt. The remote +host may continue to show stale connected state, but it cannot reach a responder +on this signing host. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` @@ -516,7 +517,7 @@ Product-local KV is persisted independently under each identity root as product id and raw product keys. Product and core JSON writes use a flushed temporary file and atomic rename. -Six scripts ship under `js/scripts/`: +Scripts under `js/scripts/` include: - `battery.ts` — the generated full-surface gate. It discovers every method from the same code-generated example manifest as the playground Diagnosis, @@ -581,11 +582,11 @@ Six scripts ship under `js/scripts/`: --auto-accept ``` - `e2e/device-removal-disconnect.sh` automates the two-host removal case. It - pairs an isolated signing host with an isolated pairing host, removes the - device interactively, and verifies the remote `Disconnected` status, cleared - pairing auth storage, and empty signing-host device list. Run `make codegen` - once in a fresh checkout, build `truapi-host-cli`, then run the script. +- `device-removal-disconnect.ts`: verifies `Connected` followed by `Disconnected`. + Run it through `e2e/device-removal-disconnect.sh`, which pairs isolated hosts, + removes the device interactively, and checks cleared pairing auth storage and + an empty signing-host device list. Run `make codegen` once in a fresh checkout, + build `truapi-host-cli`, then run the shell script. - `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this remains available as an explicit `/script ` example. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 1484c62db..4996efdb9 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -433,7 +433,7 @@ use `exec '/script '` instead. `/copy` and `/approval` are unavailable. saved pairings without starting their responders. `exec '/devices --remove '` is an explicit removal and does not ask for another confirmation. It submits `Disconnected` directly and removes local state only -after the statement is accepted. Appending `--force` still attempts that +after the statement store accepts it. Appending `--force` still attempts that submission, but warns and continues with local cleanup if it fails. ### 6.5 `--serve` @@ -569,8 +569,10 @@ account ID and print each ID with any available host and platform metadata. with an optional `0x` prefix and an optional trailing `--force`. Interactive removal uses the `[y/N]` approval and describes that only the selected peer is affected. `exec` removal runs directly. Both modes submit one `Disconnected` -message before local cleanup. If submission fails, ordinary removal preserves -the saved pairing, responder, and allowance-renewal target. Forced removal emits +message before local cleanup, allowing up to 30 seconds for the statement store +to accept it. This does not wait for a peer acknowledgement. If submission fails +or times out, ordinary removal preserves the saved pairing, responder, and +allowance-renewal target. Forced removal emits an unfiltered warning and continues with local cleanup, so the remote host may continue to show stale connected state, but it cannot reach a responder on this signing host. diff --git a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh index dfa4e8c93..20ac0abd1 100755 --- a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -184,7 +184,10 @@ wait_for_persisted_auth_session send_signing_command '/devices' wait_for_signing_pattern 'Paired devices for session' capture_signing_host -mapfile -t device_ids < <( +device_ids=() +while IFS= read -r device_id; do + device_ids+=("$device_id") +done < <( sed -nE 's/^.*(0x[[:xdigit:]]{64}) .*/\1/p' "$SIGNING_LOG" | sort -u ) if [ "${#device_ids[@]}" -ne 1 ]; then diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 44c8e5171..ec0da845c 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2480,18 +2480,20 @@ async fn disconnect_and_remove_paired_host( force: bool, ) -> Result { let paired_host = find_paired_host(session, statement_account_id)?; - let notification_failure = match session - .runtime - .disconnect_paired_host(paired_sso_peer(&paired_host)) - .await - { + let notification = tokio::time::timeout( + Duration::from_secs(30), + session + .runtime + .disconnect_paired_host(paired_sso_peer(&paired_host)), + ) + .await + .map_err(|_| "disconnect notification submission timed out".to_string()) + .and_then(|result| result.map_err(|error| error.reason)); + let notification_failure = match notification { Ok(()) => None, - Err(error) if force => Some(error.reason), - Err(error) => { - bail!( - "failed to notify paired device before removal: {}", - error.reason - ) + Err(reason) if force => Some(reason), + Err(reason) => { + bail!("failed to notify paired device before removal: {reason}") } }; remove_paired_host_locally(session, statement_account_id).await?; @@ -3284,9 +3286,14 @@ async fn signing_interactive_loop( ui.system("Paired-device removal cancelled"); continue; } - match disconnect_and_remove_paired_host(session, &statement_account_id, force).await + match ui + .drive( + input, + disconnect_and_remove_paired_host(session, &statement_account_id, force), + ) + .await? { - Ok(removal) => { + DriveResult::Complete(Ok(removal)) => { if let Some(reason) = removal.notification_failure { let (title, detail) = forced_removal_warning(&reason); ui.warning(title, Some(detail)); @@ -3300,7 +3307,8 @@ async fn signing_interactive_loop( )), ); } - Err(error) => ui.error(error.to_string()), + DriveResult::Complete(Err(error)) => ui.error(error.to_string()), + DriveResult::Cancelled => ui.system("Paired-device removal cancelled"), } } ShellCommand::Session(SessionCommand::Clear(target)) => { diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 67b601813..8d7147140 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -24,7 +24,7 @@ pub enum ProductCommand { pub enum DeviceCommand { /// List paired devices for the active managed session. List, - /// Remove the device with this statement account ID. + /// Notify and remove one device; `force` permits cleanup if notification fails. Remove { statement_account_id: [u8; 32], force: bool,