feat: SSH transport + pairing flow for TUI#138
Conversation
Implements the client/gateway pairing flow for v0.4.0: **Core pairing module** (rustyclaw-core/src/pairing/): - client_keys.rs: Ed25519 keypair generation and storage - authorized.rs: Manage authorized_clients file (gateway side) - qr.rs: QR code generation (ASCII + PNG) for pairing data - fingerprint.rs: Key fingerprints and visual randomart **TUI pairing dialog** (components/pairing_dialog.rs): - Multi-step pairing wizard - Public key display with fingerprint art - Gateway host/port input - Progress states (ShowKey → EnterGateway → Connecting → Complete) **Gateway CLI pairing commands**: - `rustyclaw-gateway pair list` — show authorized clients - `rustyclaw-gateway pair add <KEY>` — authorize a client - `rustyclaw-gateway pair remove <FINGERPRINT>` — revoke access - `rustyclaw-gateway pair qr` — show gateway's pairing QR **New features**: - ssh: requires russh, russh-keys, rand_core, sha2 - qr: requires image (PNG encoding) Roadmap updated with implementation progress.
- Rewrote pairing_dialog.rs for iocraft (was ratatui) - Added pairing state to TuiRoot (step, field, keys, host/port) - Added Ctrl+Shift+P keybinding to open pairing dialog - Added key handlers for dialog navigation (Enter/Esc/Tab/typing) - Added pairing props to Root component - Made fingerprint functions cfg-gated for ssh feature - Added stubs for non-ssh builds Dialog flow: ShowKey → EnterGateway → Connecting → Complete SSH connection not yet implemented (TODO in key handler).
…sues - Changed ServerPayload::Hello field from 'version' to 'agent' - Changed ServerPayload::Status field from 'status_type' to 'status' - Removed StatusType::Info usage (doesn't exist in enum) - Fixed pairing dialog match arms with explicit AnyElement type conversion Gateway and TUI now compile cleanly.
- Added PairingConnect/PairingSuccess/PairingError events - Added pairing.rs module with connect_and_pair function - Added ssh feature flag to TUI crate (forwards to rustyclaw-core/ssh) - Added ClientKeyPair::load_or_generate() and load_private_key() methods - Added listen_addr field to SshConfig - Wired Enter key in EnterGateway step to initiate async SSH connection - Events update dialog state on success/failure With --features ssh, the TUI now: 1. Generates/loads Ed25519 keypair on Ctrl+Shift+P 2. Shows public key and fingerprint art 3. Accepts gateway host/port 4. Initiates SSH connection on Enter 5. Reports success or shows error
- Export generate_pairing_qr_ascii from pairing module - Add pairing_qr_ascii state variable - Generate QR when keypair is loaded (Ctrl+Shift+P) - Pass QR through RootProps to PairingDialog With qr feature, dialog now shows scannable QR code on ShowKey step instead of just fingerprint art.
| #[cfg(feature = "qr")] | ||
| { | ||
| use rustyclaw_core::pairing::{PairingData, generate_pairing_qr_ascii}; | ||
|
|
||
| // Generate gateway pairing data | ||
| // For now, we use a placeholder key - in production, this would be the host key's public part | ||
| let data = PairingData::gateway( | ||
| "ssh-ed25519 (host key would go here)", | ||
| &host, | ||
| Some("RustyClaw Gateway".to_string()), | ||
| ); | ||
|
|
||
| match generate_pairing_qr_ascii(&data) { | ||
| Ok(qr) => { | ||
| println!("{}", t::header("Gateway Pairing QR Code")); | ||
| println!(); | ||
| println!("{}", qr); | ||
| println!(); | ||
| println!("Scan this QR code with a RustyClaw client to pair."); | ||
| println!("Gateway address: {}", t::info(&host)); | ||
| } | ||
| Err(e) => { | ||
| eprintln!("{} Failed to generate QR code: {}", t::icon_err(""), e); | ||
| std::process::exit(1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(not(feature = "qr"))] | ||
| { | ||
| eprintln!("{} QR code feature not enabled", t::icon_err("")); | ||
| eprintln!("Rebuild with: cargo build --features qr"); | ||
| std::process::exit(1); | ||
| } |
There was a problem hiding this comment.
🟡 'pair qr' command always fails because qr feature is not enabled in CLI
The gateway CLI's pair qr subcommand (crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs:593) calls generate_pairing_qr_ascii from rustyclaw_core::pairing, but this function is gated on #[cfg(feature = "qr")] (crates/rustyclaw-core/src/pairing/qr.rs:118). The CLI crate's Cargo.toml doesn't enable the qr feature on rustyclaw-core, so the stub implementation at line 162 is used, which always returns anyhow::bail!("QR code feature not enabled"). The generate_pairing_qr_ascii function only uses the qrcode crate (which is a non-optional dependency), not the image crate that the qr feature gates, so the feature gate is overly broad.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
|
||
| let shared_cfg = shared_config.clone(); | ||
| let shared_ctx = shared_model_ctx.clone(); | ||
| let session_clone = copilot_session.clone(); |
There was a problem hiding this comment.
🔴 SSH handler uses stale copilot_session snapshot instead of shared live-updating reference
In the SSH accept handler, copilot_session.clone() at line 562 captures the initial Option<Arc<CopilotSession>> snapshot created at gateway startup (crates/rustyclaw-core/src/gateway/mod.rs:357). In contrast, the WebSocket handler at line 505 correctly uses shared_copilot_session.clone(), which is the SharedCopilotSession (Arc<RwLock<...>>) that gets updated when models are reloaded or Copilot tokens are refreshed. Furthermore, handle_transport_connection at line 606 accepts copilot_session: Option<Arc<CopilotSession>> (a static snapshot) instead of SharedCopilotSession like handle_connection does at crates/rustyclaw-core/src/gateway/mod.rs:903. This means SSH connections will always use the initial Copilot session token, even after it expires or the model is changed — causing authentication failures for Copilot-based model calls over SSH.
Was this helpful? React with 👍 or 👎 to provide feedback.
| fn truncate_key(key: &str, max_len: usize) -> String { | ||
| if key.len() <= max_len { | ||
| key.to_string() | ||
| } else { | ||
| let half = (max_len - 3) / 2; | ||
| format!("{}...{}", &key[..half], &key[key.len() - half..]) | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 truncate_key panics on non-ASCII UTF-8 strings due to byte-based slicing
The truncate_key function uses byte-based string slicing (&key[..half] and &key[key.len() - half..]) which will panic at runtime if the slice boundary falls inside a multi-byte UTF-8 character. While SSH key types and base64 data are ASCII, the optional comment field (e.g. user@host) could contain non-ASCII characters. If the total key string exceeds max_len (66) and the byte boundaries land within multi-byte chars, Rust panics with byte index N is not a char boundary.
| fn truncate_key(key: &str, max_len: usize) -> String { | |
| if key.len() <= max_len { | |
| key.to_string() | |
| } else { | |
| let half = (max_len - 3) / 2; | |
| format!("{}...{}", &key[..half], &key[key.len() - half..]) | |
| } | |
| } | |
| fn truncate_key(key: &str, max_len: usize) -> String { | |
| if key.len() <= max_len { | |
| key.to_string() | |
| } else { | |
| let half = (max_len - 3) / 2; | |
| let start: String = key.chars().take(half).collect(); | |
| let end: String = key.chars().rev().take(half).collect::<Vec<_>>().into_iter().rev().collect(); | |
| format!("{}...{}", start, end) | |
| } | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| Some(GatewayCommands::Pair(pair_cmd)) => { | ||
| return handle_pair_command(pair_cmd).await; | ||
| } |
There was a problem hiding this comment.
🟡 pair subcommand ignores custom --settings-dir/--profile, operates on wrong authorized_clients file
The handle_pair_command function at crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs:504 uses default_authorized_clients_path() which always resolves to ~/.rustyclaw/authorized_clients, ignoring any --settings-dir or --profile CLI overrides. However, the config is already loaded with overrides applied (lines 130-132) before the Pair match arm is reached (line 182), but the config is never passed to handle_pair_command. The SSH server uses config.settings_dir.join("authorized_clients") at crates/rustyclaw-core/src/gateway/mod.rs:438, so when a custom settings directory is in use, rustyclaw-gateway pair add/remove/list will read/write a different file than the one the SSH server actually checks.
Prompt for agents
The handle_pair_command function at line 504 uses default_authorized_clients_path() which hardcodes ~/.rustyclaw/authorized_clients. It should instead receive the loaded Config (or at least the settings_dir path) from main() and use config.settings_dir.join("authorized_clients") to stay consistent with the SSH server's authorized_clients_path resolution at gateway/mod.rs:438.
The fix involves:
1. Change handle_pair_command signature to accept a &Config or PathBuf parameter
2. At the call site (line 183), pass the already-loaded config
3. Inside handle_pair_command, use config.settings_dir.join("authorized_clients") instead of default_authorized_clients_path()
Was this helpful? React with 👍 or 👎 to provide feedback.
| #[cfg(not(feature = "ssh"))] | ||
| fn calculate_fingerprint(_public_key_openssh: &str) -> String { | ||
| "SHA256:unavailable".to_string() | ||
| } |
There was a problem hiding this comment.
🔴 All fingerprints collapse to "SHA256:unavailable" when ssh feature is disabled, making pair remove delete all clients
In authorized.rs, when the ssh feature is not enabled, calculate_fingerprint() (lines 294–297) returns the constant "SHA256:unavailable" for every key. Since the pair CLI subcommands in rustyclaw-gateway.rs (lines 57–86) are always available regardless of feature flags, pair remove SHA256:unavailable would silently remove ALL authorized clients via remove_by_fingerprint at crates/rustyclaw-core/src/pairing/authorized.rs:110-113, which retains only clients whose fingerprint doesn't match. Similarly, pair list shows identical fingerprints for all clients, and pair remove <real-fingerprint> never matches anything. The CLI crate (rustyclaw-cli/Cargo.toml) does not enable the ssh feature by default.
Prompt for agents
The calculate_fingerprint function in crates/rustyclaw-core/src/pairing/authorized.rs returns a constant string for ALL keys when the ssh feature is disabled. This makes pair remove dangerous (could delete all clients). There are two approaches to fix this: (1) Move the fingerprint calculation to use base64 + sha2 unconditionally by making sha2 a non-optional dependency (or adding a separate feature flag for it), so fingerprints work regardless of the ssh feature. The base64 crate is already non-optional. (2) Alternatively, gate the pair CLI subcommands behind the ssh feature in rustyclaw-gateway.rs, or at least check at runtime and emit a clear error if fingerprinting is unavailable rather than silently producing broken data.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
@copilot resolve the conflicts and fix CI breakage. |
…nflicts) Agent-Logs-Url: https://github.com/rexlunae/RustyClaw/sessions/f22e383a-bf46-4359-82d5-e9cf33415211 Co-authored-by: rexlunae <6726134+rexlunae@users.noreply.github.com>
| let client_guard = client.lock().await; | ||
| if let Some(event) = client_guard.recv().await { | ||
| drop(client_guard); |
There was a problem hiding this comment.
🔴 Desktop client deadlocks: outer Mutex held during blocking recv() prevents message sends
In app.rs, GatewayClient is wrapped in Arc<Mutex<GatewayClient>> (line 19). The event loop at line 44 acquires this outer Mutex and then calls client_guard.recv().await, which blocks waiting for the next event while holding the lock. Meanwhile, on_submit at line 66 and other handlers also need to acquire this same Mutex to call chat()/send(). Since recv() blocks indefinitely until an event arrives, all send operations are blocked. The GatewayClient.send() method only needs &self (it uses an internal mpsc::Sender), so the outer Mutex is unnecessary and creates this deadlock.
Fix approach
Replace Arc<Mutex<GatewayClient>> with just Arc<GatewayClient>. Both send() and recv() only require &self — send() uses cmd_tx (a channel sender), and recv() uses its own internal Arc<Mutex<mpsc::Receiver>>. No external synchronization is needed.
Prompt for agents
In crates/rustyclaw-desktop/src/app.rs, the GatewayClient is wrapped in Arc<Mutex<GatewayClient>> at line 19. The event loop at lines 44-46 acquires the outer Mutex lock and holds it while blocking in recv(). This prevents all other handlers (on_submit, on_new_thread, on_switch_thread) from calling send()/chat() on the client, because they also need to acquire the same Mutex.
Since GatewayClient.send() only needs &self (it uses an internal mpsc::Sender<GatewayCommand>), and GatewayClient.recv() only needs &self (it has its own internal Arc<Mutex<mpsc::Receiver>>), no outer Mutex is needed.
The fix:
1. Change Signal<Option<Arc<Mutex<GatewayClient>>>> to Signal<Option<Arc<GatewayClient>>> throughout app.rs
2. Remove all .lock().await calls on the gateway client
3. In connect_to_gateway, wrap with Arc::new(client) instead of Arc::new(Mutex::new(client))
4. In all handlers, use client.chat(...) / client.send(...) directly without locking
Was this helpful? React with 👍 or 👎 to provide feedback.
…ars in TUI Agent-Logs-Url: https://github.com/rexlunae/RustyClaw/sessions/f22e383a-bf46-4359-82d5-e9cf33415211 Co-authored-by: rexlunae <6726134+rexlunae@users.noreply.github.com>
Done. Merged
All 460 tests pass and Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot CI is broken, fix. |
…g parse failure Agent-Logs-Url: https://github.com/rexlunae/RustyClaw/sessions/020fa889-b3c8-4522-9eb3-505b07987cdc Co-authored-by: rexlunae <6726134+rexlunae@users.noreply.github.com>
Fixed in commit 6601402. The previous conflict resolution in Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
origin/maininto feature branch (resolve Cargo.lock conflicts)ssh.rs: add missing imports (from main merge)mod.rs: add missingSshServerandSocketAddrimports under#[cfg(feature = "ssh")]app.rs: declare pairing state variables asmutfingerprint.rstest: gate line-count assertion with#[cfg(feature = "ssh")]Cargo.lockfrom scratch to fix duplicate package entries causingcargo: failed to parse lock fileerrorcargo check --features fullcompiles cleanly