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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions rust/crates/truapi-host-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ Commands always start with `/`:
| `/pair <image-path>` | Decode a pairing QR from a PNG, JPEG, or WebP image (signing host). |
| `/pair <url>` | 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 <statement-account-id>` | Remove one paired device by its 32-byte statement account ID. |
| `/devices --remove <statement-account-id>` | Disconnect and remove one paired device by its 32-byte statement account ID. |
| `/devices --remove <statement-account-id> --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. |
Expand Down Expand Up @@ -384,8 +385,15 @@ statement lifetime.
order with available host and platform metadata. Interactive
`/devices --remove <statement-account-id>` 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. 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 <name>` permanently deletes that session's local signer
keys, scripts, core/product storage, and permissions. `/session --clear-all`
Expand Down Expand Up @@ -420,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
Expand Down Expand Up @@ -559,6 +568,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 <primary username>`; this
remains available as an explicit `/script <path>` example.
- `signing-smoke.ts` — a focused product-account signing check.
Expand Down
18 changes: 14 additions & 4 deletions rust/crates/truapi-host-cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -431,7 +432,9 @@ use `exec '/script <path>'` 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
<statement-account-id>'` 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. Appending `--force` still attempts that
submission, but warns and continues with local cleanup if it fails.

### 6.5 `--serve`

Expand Down Expand Up @@ -536,7 +539,8 @@ Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases.
| `/pair <url>` | 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 <statement-account-id>` | no | yes | Remove one paired device by its 32-byte statement account ID. |
| `/devices --remove <statement-account-id>` | no | yes | Disconnect and remove one paired device by its 32-byte statement account ID. |
| `/devices --remove <statement-account-id> --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. |
Expand All @@ -562,8 +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.
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
Expand Down
230 changes: 230 additions & 0 deletions rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/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_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))
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'
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_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

send_signing_command '/devices'
wait_for_signing_pattern 'No paired devices for session'

echo "DEVICE_REMOVE_E2E_OK"
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// <reference path="../runner.ts" />
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<void>((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<never>(() => {});
Loading