mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Serde alias on `onboard_completed` preserves existing settings.json files that still have the old `setup_completed` key. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1268 lines
39 KiB
Rust
1268 lines
39 KiB
Rust
//! Tool management CLI commands.
|
|
//!
|
|
//! Commands for installing, listing, removing, and authenticating WASM tools.
|
|
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command as ProcessCommand;
|
|
use std::sync::Arc;
|
|
|
|
use clap::Subcommand;
|
|
use tokio::fs;
|
|
|
|
use crate::config::Config;
|
|
use crate::history::Store;
|
|
use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore};
|
|
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
|
|
|
/// Default tools directory.
|
|
fn default_tools_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.map(|h| h.join(".ironclaw").join("tools"))
|
|
.unwrap_or_else(|| PathBuf::from(".ironclaw/tools"))
|
|
}
|
|
|
|
#[derive(Subcommand, Debug, Clone)]
|
|
pub enum ToolCommand {
|
|
/// Install a WASM tool from source directory or .wasm file
|
|
Install {
|
|
/// Path to tool source directory (with Cargo.toml) or .wasm file
|
|
path: PathBuf,
|
|
|
|
/// Tool name (defaults to directory/file name)
|
|
#[arg(short, long)]
|
|
name: Option<String>,
|
|
|
|
/// Path to capabilities JSON file (auto-detected if not specified)
|
|
#[arg(long)]
|
|
capabilities: Option<PathBuf>,
|
|
|
|
/// Target directory for installation (default: ~/.ironclaw/tools/)
|
|
#[arg(short, long)]
|
|
target: Option<PathBuf>,
|
|
|
|
/// Build in release mode (default: true)
|
|
#[arg(long, default_value = "true")]
|
|
release: bool,
|
|
|
|
/// Skip compilation (use existing .wasm file)
|
|
#[arg(long)]
|
|
skip_build: bool,
|
|
|
|
/// Force overwrite if tool already exists
|
|
#[arg(short, long)]
|
|
force: bool,
|
|
},
|
|
|
|
/// List installed tools
|
|
List {
|
|
/// Directory to list tools from (default: ~/.ironclaw/tools/)
|
|
#[arg(short, long)]
|
|
dir: Option<PathBuf>,
|
|
|
|
/// Show detailed information
|
|
#[arg(short, long)]
|
|
verbose: bool,
|
|
},
|
|
|
|
/// Remove an installed tool
|
|
Remove {
|
|
/// Name of the tool to remove
|
|
name: String,
|
|
|
|
/// Directory to remove tool from (default: ~/.ironclaw/tools/)
|
|
#[arg(short, long)]
|
|
dir: Option<PathBuf>,
|
|
},
|
|
|
|
/// Show information about a tool
|
|
Info {
|
|
/// Name of the tool or path to .wasm file
|
|
name_or_path: String,
|
|
|
|
/// Directory to look for tool (default: ~/.ironclaw/tools/)
|
|
#[arg(short, long)]
|
|
dir: Option<PathBuf>,
|
|
},
|
|
|
|
/// Configure authentication for a tool
|
|
Auth {
|
|
/// Name of the tool
|
|
name: String,
|
|
|
|
/// Directory to look for tool (default: ~/.ironclaw/tools/)
|
|
#[arg(short, long)]
|
|
dir: Option<PathBuf>,
|
|
|
|
/// User ID for storing the secret (default: "default")
|
|
#[arg(short, long, default_value = "default")]
|
|
user: String,
|
|
},
|
|
}
|
|
|
|
/// Run a tool command.
|
|
pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
|
match cmd {
|
|
ToolCommand::Install {
|
|
path,
|
|
name,
|
|
capabilities,
|
|
target,
|
|
release,
|
|
skip_build,
|
|
force,
|
|
} => install_tool(path, name, capabilities, target, release, skip_build, force).await,
|
|
ToolCommand::List { dir, verbose } => list_tools(dir, verbose).await,
|
|
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
|
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
|
|
ToolCommand::Auth { name, dir, user } => auth_tool(name, dir, user).await,
|
|
}
|
|
}
|
|
|
|
/// Install a WASM tool.
|
|
async fn install_tool(
|
|
path: PathBuf,
|
|
name: Option<String>,
|
|
capabilities: Option<PathBuf>,
|
|
target: Option<PathBuf>,
|
|
release: bool,
|
|
skip_build: bool,
|
|
force: bool,
|
|
) -> anyhow::Result<()> {
|
|
let target_dir = target.unwrap_or_else(default_tools_dir);
|
|
|
|
// Determine if path is a directory (source) or .wasm file
|
|
let metadata = fs::metadata(&path).await?;
|
|
|
|
let (wasm_path, tool_name, caps_path) = if metadata.is_dir() {
|
|
// Source directory, need to build
|
|
let cargo_toml = path.join("Cargo.toml");
|
|
if !cargo_toml.exists() {
|
|
anyhow::bail!(
|
|
"No Cargo.toml found in {}. Expected a Rust WASM tool source directory.",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
// Extract tool name from Cargo.toml or use provided name
|
|
let tool_name = if let Some(n) = name {
|
|
n
|
|
} else {
|
|
extract_crate_name(&cargo_toml).await?
|
|
};
|
|
|
|
// Build the WASM component if not skipping
|
|
let wasm_path = if skip_build {
|
|
// Look for existing wasm file
|
|
find_wasm_artifact(&path, &tool_name, release)?
|
|
} else {
|
|
build_wasm_component(&path, release)?
|
|
};
|
|
|
|
// Look for capabilities file
|
|
let caps_path = capabilities.or_else(|| {
|
|
let candidates = [
|
|
path.join(format!("{}.capabilities.json", tool_name)),
|
|
path.join("capabilities.json"),
|
|
];
|
|
candidates.into_iter().find(|p| p.exists())
|
|
});
|
|
|
|
(wasm_path, tool_name, caps_path)
|
|
} else if path.extension().map(|e| e == "wasm").unwrap_or(false) {
|
|
// Direct .wasm file
|
|
let tool_name = name.unwrap_or_else(|| {
|
|
path.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("unknown")
|
|
.to_string()
|
|
});
|
|
|
|
// Look for capabilities file next to wasm
|
|
let caps_path = capabilities.or_else(|| {
|
|
let candidates = [
|
|
path.with_extension("capabilities.json"),
|
|
path.parent()
|
|
.map(|p| p.join(format!("{}.capabilities.json", tool_name)))
|
|
.unwrap_or_default(),
|
|
];
|
|
candidates.into_iter().find(|p| p.exists())
|
|
});
|
|
|
|
(path, tool_name, caps_path)
|
|
} else {
|
|
anyhow::bail!(
|
|
"Expected a directory with Cargo.toml or a .wasm file, got: {}",
|
|
path.display()
|
|
);
|
|
};
|
|
|
|
// Ensure target directory exists
|
|
fs::create_dir_all(&target_dir).await?;
|
|
|
|
// Target paths
|
|
let target_wasm = target_dir.join(format!("{}.wasm", tool_name));
|
|
let target_caps = target_dir.join(format!("{}.capabilities.json", tool_name));
|
|
|
|
// Check if already exists
|
|
if target_wasm.exists() && !force {
|
|
anyhow::bail!(
|
|
"Tool '{}' already exists at {}. Use --force to overwrite.",
|
|
tool_name,
|
|
target_wasm.display()
|
|
);
|
|
}
|
|
|
|
// Validate capabilities file if provided
|
|
if let Some(ref caps) = caps_path {
|
|
let content = fs::read_to_string(caps).await?;
|
|
CapabilitiesFile::from_json(&content)
|
|
.map_err(|e| anyhow::anyhow!("Invalid capabilities file {}: {}", caps.display(), e))?;
|
|
}
|
|
|
|
// Copy WASM file
|
|
println!("Installing {} to {}", tool_name, target_wasm.display());
|
|
fs::copy(&wasm_path, &target_wasm).await?;
|
|
|
|
// Copy capabilities file if present
|
|
if let Some(caps) = caps_path {
|
|
println!(" Copying capabilities from {}", caps.display());
|
|
fs::copy(&caps, &target_caps).await?;
|
|
} else {
|
|
println!(" Warning: No capabilities file found. Tool will have no permissions.");
|
|
}
|
|
|
|
// Calculate and display hash
|
|
let wasm_bytes = fs::read(&target_wasm).await?;
|
|
let hash = compute_binary_hash(&wasm_bytes);
|
|
let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
|
|
|
println!("\nInstalled successfully:");
|
|
println!(" Name: {}", tool_name);
|
|
println!(" WASM: {}", target_wasm.display());
|
|
println!(" Size: {} bytes", wasm_bytes.len());
|
|
println!(" Hash: {}", &hash_hex[..16]); // Show first 16 chars
|
|
|
|
if target_caps.exists() {
|
|
println!(" Caps: {}", target_caps.display());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Build a WASM component using cargo-component.
|
|
fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
|
println!("Building WASM component in {}...", source_dir.display());
|
|
|
|
// Check if cargo-component is available
|
|
let check = ProcessCommand::new("cargo")
|
|
.args(["component", "--version"])
|
|
.output();
|
|
|
|
if check.is_err() || !check.unwrap().status.success() {
|
|
anyhow::bail!(
|
|
"cargo-component not found. Install with: cargo install cargo-component\n\
|
|
Or use --skip-build with an existing .wasm file."
|
|
);
|
|
}
|
|
|
|
// Build command
|
|
let mut cmd = ProcessCommand::new("cargo");
|
|
cmd.current_dir(source_dir).args(["component", "build"]);
|
|
|
|
if release {
|
|
cmd.arg("--release");
|
|
}
|
|
|
|
println!(
|
|
" Running: cargo component build{}",
|
|
if release { " --release" } else { "" }
|
|
);
|
|
|
|
let output = cmd.output()?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
anyhow::bail!("Build failed:\n{}", stderr);
|
|
}
|
|
|
|
// Find the output wasm file
|
|
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
|
let profile = if release { "release" } else { "debug" };
|
|
let candidates = [
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-wasip1")
|
|
.join(profile),
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-wasip2")
|
|
.join(profile),
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-unknown-unknown")
|
|
.join(profile),
|
|
];
|
|
|
|
let target_dir = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"No WASM target directory found. Expected one of: {}",
|
|
candidates
|
|
.iter()
|
|
.map(|p| p.display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
)
|
|
})?;
|
|
|
|
// Look for .wasm files in target dir
|
|
let entries: Vec<_> = std::fs::read_dir(target_dir)?
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path()
|
|
.extension()
|
|
.map(|ext| ext == "wasm")
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
|
|
if entries.is_empty() {
|
|
anyhow::bail!(
|
|
"No .wasm file found in {}. Build may have failed.",
|
|
target_dir.display()
|
|
);
|
|
}
|
|
|
|
if entries.len() > 1 {
|
|
println!(
|
|
" Warning: Multiple .wasm files found, using first: {}",
|
|
entries[0].path().display()
|
|
);
|
|
}
|
|
|
|
let wasm_path = entries[0].path();
|
|
println!(" Built: {}", wasm_path.display());
|
|
|
|
Ok(wasm_path)
|
|
}
|
|
|
|
/// Find an existing WASM artifact without building.
|
|
fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result<PathBuf> {
|
|
let profile = if release { "release" } else { "debug" };
|
|
|
|
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
|
let target_dirs = [
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-wasip1")
|
|
.join(profile),
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-wasip2")
|
|
.join(profile),
|
|
source_dir
|
|
.join("target")
|
|
.join("wasm32-unknown-unknown")
|
|
.join(profile),
|
|
];
|
|
|
|
let snake_name = name.replace('-', "_");
|
|
|
|
// Try exact name match in any target dir first
|
|
for target_dir in &target_dirs {
|
|
let candidates = [
|
|
target_dir.join(format!("{}.wasm", name)),
|
|
target_dir.join(format!("{}.wasm", snake_name)),
|
|
];
|
|
for candidate in &candidates {
|
|
if candidate.exists() {
|
|
return Ok(candidate.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find a target dir that exists
|
|
let target_dir = target_dirs.iter().find(|p| p.exists()).ok_or_else(|| {
|
|
anyhow::anyhow!("No target directory found. Run without --skip-build to build first.")
|
|
})?;
|
|
|
|
// Fall back to any .wasm file
|
|
let entries: Vec<_> = std::fs::read_dir(target_dir)
|
|
.map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"Target directory not found: {}. Run without --skip-build.",
|
|
target_dir.display()
|
|
)
|
|
})?
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path()
|
|
.extension()
|
|
.map(|ext| ext == "wasm")
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
|
|
if entries.is_empty() {
|
|
anyhow::bail!(
|
|
"No .wasm file found in {}. Build the project first or remove --skip-build.",
|
|
target_dir.display()
|
|
);
|
|
}
|
|
|
|
Ok(entries[0].path())
|
|
}
|
|
|
|
/// Extract crate name from Cargo.toml.
|
|
async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
|
let content = fs::read_to_string(cargo_toml).await?;
|
|
|
|
// Simple TOML parsing for [package] name
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with("name") {
|
|
if let Some((_, value)) = line.split_once('=') {
|
|
let name = value.trim().trim_matches('"').trim_matches('\'');
|
|
return Ok(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"Could not extract package name from {}",
|
|
cargo_toml.display()
|
|
)
|
|
}
|
|
|
|
/// List installed tools.
|
|
async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
|
|
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
|
|
|
if !tools_dir.exists() {
|
|
println!("No tools directory found at {}", tools_dir.display());
|
|
println!("Install a tool with: ironclaw tool install <path>");
|
|
return Ok(());
|
|
}
|
|
|
|
let mut entries = fs::read_dir(&tools_dir).await?;
|
|
let mut tools = Vec::new();
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.extension().map(|e| e == "wasm").unwrap_or(false) {
|
|
let name = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
let caps_path = path.with_extension("capabilities.json");
|
|
let has_caps = caps_path.exists();
|
|
|
|
let size = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0);
|
|
|
|
tools.push((name, path, has_caps, size));
|
|
}
|
|
}
|
|
|
|
if tools.is_empty() {
|
|
println!("No tools installed in {}", tools_dir.display());
|
|
return Ok(());
|
|
}
|
|
|
|
tools.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
println!("Installed tools in {}:", tools_dir.display());
|
|
println!();
|
|
|
|
for (name, path, has_caps, size) in tools {
|
|
if verbose {
|
|
let wasm_bytes = fs::read(&path).await?;
|
|
let hash = compute_binary_hash(&wasm_bytes);
|
|
let hash_hex: String = hash.iter().take(8).map(|b| format!("{:02x}", b)).collect();
|
|
|
|
println!(" {} ({})", name, format_size(size));
|
|
println!(" Path: {}", path.display());
|
|
println!(" Hash: {}", hash_hex);
|
|
println!(" Caps: {}", if has_caps { "yes" } else { "no" });
|
|
|
|
if has_caps {
|
|
let caps_path = path.with_extension("capabilities.json");
|
|
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
|
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
|
print_capabilities_summary(&caps);
|
|
}
|
|
}
|
|
}
|
|
println!();
|
|
} else {
|
|
let caps_indicator = if has_caps { "✓" } else { "✗" };
|
|
println!(
|
|
" {} ({}, caps: {})",
|
|
name,
|
|
format_size(size),
|
|
caps_indicator
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove an installed tool.
|
|
async fn remove_tool(name: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
|
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
|
|
|
let wasm_path = tools_dir.join(format!("{}.wasm", name));
|
|
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
|
|
|
if !wasm_path.exists() {
|
|
anyhow::bail!("Tool '{}' not found in {}", name, tools_dir.display());
|
|
}
|
|
|
|
fs::remove_file(&wasm_path).await?;
|
|
println!("Removed {}", wasm_path.display());
|
|
|
|
if caps_path.exists() {
|
|
fs::remove_file(&caps_path).await?;
|
|
println!("Removed {}", caps_path.display());
|
|
}
|
|
|
|
println!("\nTool '{}' removed.", name);
|
|
Ok(())
|
|
}
|
|
|
|
/// Show information about a tool.
|
|
async fn show_tool_info(name_or_path: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
|
let wasm_path = if name_or_path.ends_with(".wasm") {
|
|
PathBuf::from(&name_or_path)
|
|
} else {
|
|
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
|
tools_dir.join(format!("{}.wasm", name_or_path))
|
|
};
|
|
|
|
if !wasm_path.exists() {
|
|
anyhow::bail!("Tool not found: {}", wasm_path.display());
|
|
}
|
|
|
|
let wasm_bytes = fs::read(&wasm_path).await?;
|
|
let hash = compute_binary_hash(&wasm_bytes);
|
|
let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
|
|
|
let name = wasm_path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("unknown");
|
|
|
|
println!("Tool: {}", name);
|
|
println!("Path: {}", wasm_path.display());
|
|
println!(
|
|
"Size: {} bytes ({})",
|
|
wasm_bytes.len(),
|
|
format_size(wasm_bytes.len() as u64)
|
|
);
|
|
println!("Hash: {}", hash_hex);
|
|
|
|
let caps_path = wasm_path.with_extension("capabilities.json");
|
|
if caps_path.exists() {
|
|
println!("\nCapabilities ({}):", caps_path.display());
|
|
let content = fs::read_to_string(&caps_path).await?;
|
|
match CapabilitiesFile::from_json(&content) {
|
|
Ok(caps) => print_capabilities_detail(&caps),
|
|
Err(e) => println!(" Error parsing: {}", e),
|
|
}
|
|
} else {
|
|
println!("\nNo capabilities file found.");
|
|
println!("Tool will have no permissions (default deny).");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Format bytes as human-readable size.
|
|
fn format_size(bytes: u64) -> String {
|
|
const KB: u64 = 1024;
|
|
const MB: u64 = KB * 1024;
|
|
|
|
if bytes >= MB {
|
|
format!("{:.1} MB", bytes as f64 / MB as f64)
|
|
} else if bytes >= KB {
|
|
format!("{:.1} KB", bytes as f64 / KB as f64)
|
|
} else {
|
|
format!("{} B", bytes)
|
|
}
|
|
}
|
|
|
|
/// Print a brief capabilities summary.
|
|
fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
|
let mut parts = Vec::new();
|
|
|
|
if let Some(ref http) = caps.http {
|
|
let hosts: Vec<_> = http.allowlist.iter().map(|e| e.host.as_str()).collect();
|
|
if !hosts.is_empty() {
|
|
parts.push(format!("http: {}", hosts.join(", ")));
|
|
}
|
|
}
|
|
|
|
if let Some(ref secrets) = caps.secrets {
|
|
if !secrets.allowed_names.is_empty() {
|
|
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
|
}
|
|
}
|
|
|
|
if let Some(ref ws) = caps.workspace {
|
|
if !ws.allowed_prefixes.is_empty() {
|
|
parts.push("workspace: read".to_string());
|
|
}
|
|
}
|
|
|
|
if !parts.is_empty() {
|
|
println!(" Perms: {}", parts.join(", "));
|
|
}
|
|
}
|
|
|
|
/// Print detailed capabilities.
|
|
fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
|
if let Some(ref http) = caps.http {
|
|
println!(" HTTP:");
|
|
for endpoint in &http.allowlist {
|
|
let methods = if endpoint.methods.is_empty() {
|
|
"*".to_string()
|
|
} else {
|
|
endpoint.methods.join(", ")
|
|
};
|
|
let path = endpoint.path_prefix.as_deref().unwrap_or("/*");
|
|
println!(" {} {} {}", methods, endpoint.host, path);
|
|
}
|
|
|
|
if !http.credentials.is_empty() {
|
|
println!(" Credentials:");
|
|
for (key, cred) in &http.credentials {
|
|
println!(" {}: {} -> {:?}", key, cred.secret_name, cred.location);
|
|
}
|
|
}
|
|
|
|
if let Some(ref rate) = http.rate_limit {
|
|
println!(
|
|
" Rate limit: {}/min, {}/hour",
|
|
rate.requests_per_minute, rate.requests_per_hour
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(ref secrets) = caps.secrets {
|
|
if !secrets.allowed_names.is_empty() {
|
|
println!(" Secrets (existence check only):");
|
|
for name in &secrets.allowed_names {
|
|
println!(" {}", name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref tool_invoke) = caps.tool_invoke {
|
|
if !tool_invoke.aliases.is_empty() {
|
|
println!(" Tool aliases:");
|
|
for (alias, real_name) in &tool_invoke.aliases {
|
|
println!(" {} -> {}", alias, real_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref ws) = caps.workspace {
|
|
if !ws.allowed_prefixes.is_empty() {
|
|
println!(" Workspace read prefixes:");
|
|
for prefix in &ws.allowed_prefixes {
|
|
println!(" {}", prefix);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configure authentication for a tool.
|
|
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
|
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
|
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
|
|
|
if !caps_path.exists() {
|
|
anyhow::bail!(
|
|
"Tool '{}' not found or has no capabilities file at {}",
|
|
name,
|
|
caps_path.display()
|
|
);
|
|
}
|
|
|
|
// Parse capabilities
|
|
let content = fs::read_to_string(&caps_path).await?;
|
|
let caps = CapabilitiesFile::from_json(&content)
|
|
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
|
|
|
// Check for auth section
|
|
let auth = caps.auth.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Tool '{}' has no auth configuration.\n\
|
|
The tool may not require authentication, or auth setup is not defined.",
|
|
name
|
|
)
|
|
})?;
|
|
|
|
let display_name = auth.display_name.as_deref().unwrap_or(&name);
|
|
|
|
let header = format!("{} Authentication", display_name);
|
|
println!();
|
|
println!("╔════════════════════════════════════════════════════════════════╗");
|
|
println!("║ {:^62}║", header);
|
|
println!("╚════════════════════════════════════════════════════════════════╝");
|
|
println!();
|
|
|
|
// Initialize secrets store
|
|
let config = Config::from_env()?;
|
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
|
|
)
|
|
})?;
|
|
|
|
let store = Store::new(&config.database).await?;
|
|
store.run_migrations().await?;
|
|
|
|
let crypto = SecretsCrypto::new(master_key.clone())?;
|
|
let secrets_store = Arc::new(PostgresSecretsStore::new(store.pool(), Arc::new(crypto)));
|
|
|
|
// Check if already configured
|
|
let already_configured = secrets_store
|
|
.exists(&user_id, &auth.secret_name)
|
|
.await
|
|
.unwrap_or(false);
|
|
|
|
if already_configured {
|
|
println!(" {} is already configured.", display_name);
|
|
println!();
|
|
print!(" Replace existing credentials? [y/N]: ");
|
|
std::io::stdout().flush()?;
|
|
|
|
let mut input = String::new();
|
|
std::io::stdin().read_line(&mut input)?;
|
|
|
|
if !input.trim().eq_ignore_ascii_case("y") {
|
|
println!();
|
|
println!(" Keeping existing credentials.");
|
|
return Ok(());
|
|
}
|
|
println!();
|
|
}
|
|
|
|
// Check for environment variable
|
|
if let Some(ref env_var) = auth.env_var {
|
|
if let Ok(token) = std::env::var(env_var) {
|
|
if !token.is_empty() {
|
|
println!(" Found {} in environment.", env_var);
|
|
println!();
|
|
|
|
// Validate if endpoint is provided
|
|
if let Some(ref validation) = auth.validation_endpoint {
|
|
print!(" Validating token...");
|
|
std::io::stdout().flush()?;
|
|
|
|
match validate_token(&token, validation, &auth.secret_name).await {
|
|
Ok(()) => {
|
|
println!(" ✓");
|
|
}
|
|
Err(e) => {
|
|
println!(" ✗");
|
|
println!(" Validation failed: {}", e);
|
|
println!();
|
|
println!(" Falling back to manual entry...");
|
|
return auth_tool_manual(&secrets_store, &user_id, &auth).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the token
|
|
save_token(&secrets_store, &user_id, &auth, &token).await?;
|
|
print_success(display_name);
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for OAuth configuration
|
|
if let Some(ref oauth) = auth.oauth {
|
|
return auth_tool_oauth(&secrets_store, &user_id, &auth, oauth).await;
|
|
}
|
|
|
|
// Fall back to manual entry
|
|
auth_tool_manual(&secrets_store, &user_id, &auth).await
|
|
}
|
|
|
|
/// OAuth browser-based login flow.
|
|
async fn auth_tool_oauth(
|
|
store: &PostgresSecretsStore,
|
|
user_id: &str,
|
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
|
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
|
) -> anyhow::Result<()> {
|
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use rand::RngCore;
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::TcpListener;
|
|
|
|
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
|
|
|
// Get client_id from config or env
|
|
let client_id = oauth
|
|
.client_id
|
|
.clone()
|
|
.or_else(|| {
|
|
oauth
|
|
.client_id_env
|
|
.as_ref()
|
|
.and_then(|env| std::env::var(env).ok())
|
|
})
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"OAuth client_id not configured.\n\
|
|
Set it in the capabilities file or via environment variable."
|
|
)
|
|
})?;
|
|
|
|
// Get client_secret if provided
|
|
let client_secret = oauth.client_secret.clone().or_else(|| {
|
|
oauth
|
|
.client_secret_env
|
|
.as_ref()
|
|
.and_then(|env| std::env::var(env).ok())
|
|
});
|
|
|
|
println!(" Starting OAuth authentication...");
|
|
println!();
|
|
|
|
// Find an available port for the callback
|
|
let mut listener = None;
|
|
let mut port = 0;
|
|
|
|
for p in 9876..=9886 {
|
|
match TcpListener::bind(format!("127.0.0.1:{}", p)).await {
|
|
Ok(l) => {
|
|
listener = Some(l);
|
|
port = p;
|
|
break;
|
|
}
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
|
|
let listener = listener.ok_or_else(|| anyhow::anyhow!("Could not find available port"))?;
|
|
let redirect_uri = format!("http://localhost:{}/callback", port);
|
|
|
|
// Generate PKCE verifier and challenge
|
|
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
|
let mut verifier_bytes = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut verifier_bytes);
|
|
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
|
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(verifier.as_bytes());
|
|
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
|
|
|
(Some(verifier), Some(challenge))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
// Build authorization URL
|
|
let mut auth_url = format!(
|
|
"{}?client_id={}&response_type=code&redirect_uri={}",
|
|
oauth.authorization_url,
|
|
urlencoding::encode(&client_id),
|
|
urlencoding::encode(&redirect_uri)
|
|
);
|
|
|
|
if !oauth.scopes.is_empty() {
|
|
auth_url.push_str(&format!(
|
|
"&scope={}",
|
|
urlencoding::encode(&oauth.scopes.join(" "))
|
|
));
|
|
}
|
|
|
|
if let Some(ref challenge) = code_challenge {
|
|
auth_url.push_str(&format!(
|
|
"&code_challenge={}&code_challenge_method=S256",
|
|
challenge
|
|
));
|
|
}
|
|
|
|
// Add extra params
|
|
for (key, value) in &oauth.extra_params {
|
|
auth_url.push_str(&format!(
|
|
"&{}={}",
|
|
urlencoding::encode(key),
|
|
urlencoding::encode(value)
|
|
));
|
|
}
|
|
|
|
println!(" Opening browser for {} login...", display_name);
|
|
println!();
|
|
|
|
if let Err(e) = open::that(&auth_url) {
|
|
println!(" Could not open browser: {}", e);
|
|
println!(" Please open this URL manually:");
|
|
println!(" {}", auth_url);
|
|
}
|
|
|
|
println!(" Waiting for authorization...");
|
|
|
|
// Wait for callback with timeout
|
|
let timeout = std::time::Duration::from_secs(300);
|
|
let code = tokio::time::timeout(timeout, async {
|
|
loop {
|
|
let (mut socket, _) = listener.accept().await?;
|
|
|
|
let mut reader = BufReader::new(&mut socket);
|
|
let mut request_line = String::new();
|
|
reader.read_line(&mut request_line).await?;
|
|
|
|
// Parse GET /callback?code=xxx HTTP/1.1
|
|
if let Some(path) = request_line.split_whitespace().nth(1) {
|
|
if path.starts_with("/callback") {
|
|
if let Some(query) = path.split('?').nth(1) {
|
|
for param in query.split('&') {
|
|
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
|
if parts.len() == 2 && parts[0] == "code" {
|
|
let code = urlencoding::decode(parts[1])
|
|
.unwrap_or_else(|_| parts[1].into())
|
|
.into_owned();
|
|
|
|
// Send success response
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\n\
|
|
Content-Type: text/html\r\n\
|
|
\r\n\
|
|
<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
|
display: flex; justify-content: center; align-items: center; \
|
|
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
|
<div style=\"text-align: center;\">\
|
|
<h1>✓ {} Connected!</h1>\
|
|
<p>You can close this window.</p>\
|
|
</div></body></html>",
|
|
display_name
|
|
);
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
let _ = socket.shutdown().await;
|
|
|
|
return Ok::<_, anyhow::Error>(code);
|
|
}
|
|
}
|
|
|
|
// Check for error
|
|
if query.contains("error=") {
|
|
let response =
|
|
"HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
return Err(anyhow::anyhow!("Authorization denied by user"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
|
let _ = socket.write_all(response.as_bytes()).await;
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("Timed out waiting for authorization"))??;
|
|
|
|
println!();
|
|
println!(" Exchanging code for token...");
|
|
|
|
// Exchange code for token
|
|
let client = reqwest::Client::new();
|
|
let mut token_params = vec![
|
|
("grant_type", "authorization_code".to_string()),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
];
|
|
|
|
if let Some(ref verifier) = code_verifier {
|
|
token_params.push(("code_verifier", verifier.to_string()));
|
|
}
|
|
|
|
// Build token request
|
|
let mut request = client.post(&oauth.token_url);
|
|
|
|
// Use Basic auth if client_secret is provided, otherwise include client_id in body
|
|
if let Some(ref secret) = client_secret {
|
|
request = request.basic_auth(&client_id, Some(secret));
|
|
} else {
|
|
token_params.push(("client_id", client_id));
|
|
}
|
|
|
|
let token_response = request.form(&token_params).send().await?;
|
|
|
|
if !token_response.status().is_success() {
|
|
let status = token_response.status();
|
|
let body = token_response.text().await.unwrap_or_default();
|
|
return Err(anyhow::anyhow!(
|
|
"Token exchange failed: {} - {}",
|
|
status,
|
|
body
|
|
));
|
|
}
|
|
|
|
let token_data: serde_json::Value = token_response.json().await?;
|
|
let access_token = token_data
|
|
.get(&oauth.access_token_field)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"No {} in token response: {:?}",
|
|
oauth.access_token_field,
|
|
token_data
|
|
)
|
|
})?;
|
|
|
|
// Save the token
|
|
save_token(store, user_id, auth, access_token).await?;
|
|
|
|
// Extract any additional info for display
|
|
let workspace_name = token_data
|
|
.get("workspace_name")
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| token_data.get("team_name").and_then(|v| v.as_str()));
|
|
|
|
println!();
|
|
println!(" ✓ {} connected!", display_name);
|
|
if let Some(workspace) = workspace_name {
|
|
println!(" Workspace: {}", workspace);
|
|
}
|
|
println!();
|
|
println!(" The tool can now access the API.");
|
|
println!();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Manual token entry flow.
|
|
async fn auth_tool_manual(
|
|
store: &PostgresSecretsStore,
|
|
user_id: &str,
|
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
|
) -> anyhow::Result<()> {
|
|
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
|
|
|
// Show instructions
|
|
if let Some(ref instructions) = auth.instructions {
|
|
println!(" Setup instructions:");
|
|
println!();
|
|
for line in instructions.lines() {
|
|
println!(" {}", line);
|
|
}
|
|
println!();
|
|
}
|
|
|
|
// Offer to open setup URL
|
|
if let Some(ref url) = auth.setup_url {
|
|
print!(" Press Enter to open setup page (or 's' to skip): ");
|
|
std::io::stdout().flush()?;
|
|
|
|
let mut input = String::new();
|
|
std::io::stdin().read_line(&mut input)?;
|
|
|
|
if !input.trim().eq_ignore_ascii_case("s") {
|
|
if let Err(e) = open::that(url) {
|
|
println!(" Could not open browser: {}", e);
|
|
println!(" Please open manually: {}", url);
|
|
} else {
|
|
println!(" Opening browser...");
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
|
|
// Show token hint
|
|
if let Some(ref hint) = auth.token_hint {
|
|
println!(" Token format: {}", hint);
|
|
println!();
|
|
}
|
|
|
|
// Prompt for token
|
|
print!(" Paste your token: ");
|
|
std::io::stdout().flush()?;
|
|
|
|
let token = read_hidden_input()?;
|
|
println!();
|
|
|
|
if token.is_empty() {
|
|
println!(" No token provided. Aborting.");
|
|
return Ok(());
|
|
}
|
|
|
|
// Validate if endpoint is provided
|
|
if let Some(ref validation) = auth.validation_endpoint {
|
|
print!(" Validating token...");
|
|
std::io::stdout().flush()?;
|
|
|
|
match validate_token(&token, validation, &auth.secret_name).await {
|
|
Ok(()) => {
|
|
println!(" ✓");
|
|
}
|
|
Err(e) => {
|
|
println!(" ✗");
|
|
println!(" Validation failed: {}", e);
|
|
println!();
|
|
print!(" Save anyway? [y/N]: ");
|
|
std::io::stdout().flush()?;
|
|
|
|
let mut confirm = String::new();
|
|
std::io::stdin().read_line(&mut confirm)?;
|
|
|
|
if !confirm.trim().eq_ignore_ascii_case("y") {
|
|
println!(" Aborting.");
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save the token
|
|
save_token(store, user_id, auth, &token).await?;
|
|
print_success(display_name);
|
|
Ok(())
|
|
}
|
|
|
|
/// Read input with hidden characters.
|
|
fn read_hidden_input() -> anyhow::Result<String> {
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyModifiers},
|
|
terminal,
|
|
};
|
|
|
|
let mut input = String::new();
|
|
|
|
terminal::enable_raw_mode()?;
|
|
|
|
loop {
|
|
if let Event::Key(key_event) = event::read()? {
|
|
match key_event.code {
|
|
KeyCode::Enter => {
|
|
break;
|
|
}
|
|
KeyCode::Backspace => {
|
|
if !input.is_empty() {
|
|
input.pop();
|
|
print!("\x08 \x08");
|
|
std::io::stdout().flush()?;
|
|
}
|
|
}
|
|
KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
terminal::disable_raw_mode()?;
|
|
return Err(anyhow::anyhow!("Interrupted"));
|
|
}
|
|
KeyCode::Char(c) => {
|
|
input.push(c);
|
|
print!("*");
|
|
std::io::stdout().flush()?;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
terminal::disable_raw_mode()?;
|
|
|
|
Ok(input)
|
|
}
|
|
|
|
/// Validate a token against the validation endpoint.
|
|
async fn validate_token(
|
|
token: &str,
|
|
validation: &crate::tools::wasm::ValidationEndpointSchema,
|
|
_secret_name: &str,
|
|
) -> anyhow::Result<()> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()?;
|
|
|
|
// Build request based on method
|
|
let request = match validation.method.to_uppercase().as_str() {
|
|
"GET" => client.get(&validation.url),
|
|
"POST" => client.post(&validation.url),
|
|
_ => client.get(&validation.url),
|
|
};
|
|
|
|
// Add authorization header (assume Bearer for now, could be extended)
|
|
let response = request
|
|
.header("Authorization", format!("Bearer {}", token))
|
|
.header("Notion-Version", "2022-06-28") // Notion-specific, but harmless for others
|
|
.send()
|
|
.await?;
|
|
|
|
if response.status().as_u16() == validation.success_status {
|
|
Ok(())
|
|
} else {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
Err(anyhow::anyhow!(
|
|
"HTTP {} (expected {}): {}",
|
|
status,
|
|
validation.success_status,
|
|
if body.len() > 100 {
|
|
format!("{}...", &body[..100])
|
|
} else {
|
|
body
|
|
}
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Save token to secrets store.
|
|
async fn save_token(
|
|
store: &PostgresSecretsStore,
|
|
user_id: &str,
|
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
|
token: &str,
|
|
) -> anyhow::Result<()> {
|
|
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
|
|
|
if let Some(ref provider) = auth.provider {
|
|
params = params.with_provider(provider);
|
|
}
|
|
|
|
store
|
|
.create(user_id, params)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Print success message.
|
|
fn print_success(display_name: &str) {
|
|
println!();
|
|
println!(" ✓ {} connected!", display_name);
|
|
println!();
|
|
println!(" The tool can now access the API.");
|
|
println!();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_format_size() {
|
|
assert_eq!(format_size(500), "500 B");
|
|
assert_eq!(format_size(1024), "1.0 KB");
|
|
assert_eq!(format_size(1536), "1.5 KB");
|
|
assert_eq!(format_size(1048576), "1.0 MB");
|
|
assert_eq!(format_size(2621440), "2.5 MB");
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_tools_dir() {
|
|
let dir = default_tools_dir();
|
|
assert!(dir.to_string_lossy().contains(".ironclaw"));
|
|
assert!(dir.to_string_lossy().contains("tools"));
|
|
}
|
|
}
|