Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
105 changes: 104 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

165 changes: 165 additions & 0 deletions crates/rustyclaw-cli/src/bin/rustyclaw-gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ enum GatewayCommands {
#[arg(long)]
json: bool,
},
/// Manage SSH pairing and authorized clients
#[command(subcommand)]
Pair(PairCommands),
}

#[derive(Debug, Subcommand)]
enum PairCommands {
/// List authorized clients
List,
/// Add a new authorized client
Add {
/// Public key in OpenSSH format (ssh-ed25519 AAAA...)
#[arg(value_name = "PUBLIC_KEY")]
key: String,
/// Optional name/comment for the client
#[arg(long, short)]
name: Option<String>,
},
/// Remove an authorized client by fingerprint
Remove {
/// Key fingerprint (SHA256:...)
#[arg(value_name = "FINGERPRINT")]
fingerprint: String,
},
/// Show pairing QR code for this gateway
Qr {
/// Gateway host:port (required for QR generation)
#[arg(long, value_name = "HOST:PORT")]
host: String,
},
}

#[derive(Debug, clap::Args)]
Expand Down Expand Up @@ -149,6 +179,11 @@ async fn main() -> Result<()> {
}
return Ok(());
}
Some(GatewayCommands::Pair(pair_cmd)) => {
return handle_pair_command(pair_cmd).await;
}
Comment on lines +182 to +184
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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()
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return Ok(());
}
None => RunArgs::default(),
};

Expand Down Expand Up @@ -466,3 +501,133 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> {
transport.close().await?;
Ok(())
}

/// Handle pairing subcommands.
async fn handle_pair_command(cmd: PairCommands) -> Result<()> {
use rustyclaw_core::pairing::{
default_authorized_clients_path,
load_authorized_clients,
add_authorized_client,
remove_authorized_client,
};

let auth_path = default_authorized_clients_path();

match cmd {
PairCommands::List => {
let clients = load_authorized_clients(&auth_path)?;

if clients.clients.is_empty() {
println!("{}", t::muted("No authorized clients"));
println!();
println!("Add a client with:");
println!(" {} pair add <PUBLIC_KEY> --name <NAME>", t::info("rustyclaw-gateway"));
return Ok(());
}

println!("{}", t::header("Authorized Clients"));
println!();

for (i, client) in clients.clients.iter().enumerate() {
let name = client.comment.as_deref().unwrap_or("(unnamed)");
println!(
"{}. {} {}",
i + 1,
t::info(name),
t::muted(&format!("({})", &client.fingerprint))
);
}

println!();
println!(
"{} {}",
t::muted("File:"),
auth_path.display()
);
}

PairCommands::Add { key, name } => {
match add_authorized_client(&auth_path, &key, name.as_deref()) {
Ok(client) => {
println!(
"{} Added client: {}",
t::icon_ok(""),
t::info(client.comment.as_deref().unwrap_or("(unnamed)"))
);
println!(
" {} {}",
t::muted("Fingerprint:"),
client.fingerprint
);
}
Err(e) => {
eprintln!("{} Failed to add client: {}", t::icon_err(""), e);
std::process::exit(1);
}
}
}

PairCommands::Remove { fingerprint } => {
match remove_authorized_client(&auth_path, &fingerprint) {
Ok(true) => {
println!(
"{} Removed client with fingerprint: {}",
t::icon_ok(""),
fingerprint
);
}
Ok(false) => {
eprintln!(
"{} No client found with fingerprint: {}",
t::icon_err(""),
fingerprint
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{} Failed to remove client: {}", t::icon_err(""), e);
std::process::exit(1);
}
}
}

PairCommands::Qr { host } => {
#[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);
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 '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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

Ok(())
}
4 changes: 4 additions & 0 deletions crates/rustyclaw-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mcp = ["dep:rmcp", "dep:schemars"]
matrix = ["dep:matrix-sdk"]
whatsapp = ["dep:wa-rs", "dep:wa-rs-sqlite-storage", "dep:wa-rs-tokio-transport", "dep:wa-rs-ureq-http"]
ssh = ["dep:russh", "dep:russh-keys", "dep:rand_core", "dep:sha2"]
qr = ["dep:image"]
# CLI-based messengers (tier 1) - no heavy deps, just HTTP
signal-cli = []
matrix-cli = [] # Matrix CLI messenger using HTTP API (no external deps)
Expand Down Expand Up @@ -106,6 +107,9 @@ russh-keys = { version = "0.44", optional = true }
rand_core = { version = "0.6", optional = true }
sha2 = { version = "0.10", optional = true }

# QR code generation (optional)
image = { version = "0.25", default-features = false, features = ["png"], optional = true }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

Expand Down
Loading
Loading