Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.

159 changes: 156 additions & 3 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,9 @@ 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.

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

Expand Down Expand Up @@ -386,7 +419,7 @@ async fn main() -> Result<()> {
///
/// This mode is used when the gateway is invoked via OpenSSH's subsystem
/// mechanism. Instead of listening on TCP, we read/write frames on stdin/stdout.
async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> {
async fn run_ssh_stdio_mode(config: Config, _args: RunArgs) -> Result<()> {
use rustyclaw_core::gateway::{StdioTransport, Transport};

// Get username from SSH environment
Expand Down Expand Up @@ -424,7 +457,7 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> {
std::sync::Arc::new(tokio::sync::Mutex::new(vault));

// ── Resolve model context ────────────────────────────────────────────
let model_ctx = {
let _model_ctx = {
let env_key = std::env::var("RUSTYCLAW_MODEL_API_KEY").ok();

if let Some(ref key) = env_key {
Expand All @@ -447,7 +480,7 @@ async fn run_ssh_stdio_mode(config: Config, args: RunArgs) -> Result<()> {
if let Some(url) = config.clawhub_url.as_deref() {
sm.set_registry(url, config.clawhub_token.clone());
}
let shared_skills: rustyclaw_core::gateway::SharedSkillManager =
let _shared_skills: rustyclaw_core::gateway::SharedSkillManager =
std::sync::Arc::new(tokio::sync::Mutex::new(sm));

// Set up cancellation
Expand All @@ -466,3 +499,123 @@ 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::heading("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_fail(""), 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_fail(""),
fingerprint
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{} Failed to remove client: {}", t::icon_fail(""), e);
std::process::exit(1);
}
}
}

PairCommands::Qr { host } => {
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::heading("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_fail(""), e);
std::process::exit(1);
}
}
}
}

Ok(())
}
2 changes: 1 addition & 1 deletion crates/rustyclaw-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ pub mod gateway;

// Re-export handlers for use in main.rs
pub use gateway::{
handle_reload_result, handle_restart, handle_run, handle_start, handle_status, handle_stop,
handle_restart, handle_run, handle_start, handle_status, handle_stop,
parse_gateway_defaults,
};
Loading
Loading