Skip to content
Open
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
114 changes: 106 additions & 8 deletions src/cli/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,41 @@ fn default_tools_dir() -> PathBuf {
ironclaw_base_dir().join("tools")
}

/// Default channels directory.
fn default_channels_dir() -> PathBuf {
ironclaw_base_dir().join("channels")
}

/// Resolve a capabilities file for `name`.
///
/// The primary lookup is the requested directory. If no explicit directory was
/// provided, prefer the default channels directory so registry-installed WASM
/// channels win over same-named tools. Fall back to the tools directory only
/// if the channel-scoped file is absent.
fn resolve_capabilities_path_in_base(base_dir: &Path, name: &str, dir: Option<PathBuf>) -> PathBuf {
let primary_dir = dir.clone().unwrap_or_else(|| base_dir.join("channels"));
let primary_path = primary_dir.join(format!("{}.capabilities.json", name));
if primary_path.exists() {
return primary_path;
}

if dir.is_none() {
let tool_path = base_dir
.join("tools")
.join(format!("{}.capabilities.json", name));
if tool_path.exists() {
return tool_path;
}
}

primary_path
}

fn resolve_capabilities_path(name: &str, dir: Option<PathBuf>) -> PathBuf {
let base_dir = ironclaw_base_dir();
resolve_capabilities_path_in_base(&base_dir, name, dir)
}

#[derive(Subcommand, Debug, Clone)]
pub enum ToolCommand {
/// Install a WASM tool from source directory or .wasm file
Expand Down Expand Up @@ -557,14 +592,17 @@ async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sy
/// Configure authentication for a tool.
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
validate_tool_name(&name)?;
let tools_dir = dir.unwrap_or_else(default_tools_dir);
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
let tools_dir = dir.clone().unwrap_or_else(default_tools_dir);
let caps_path = resolve_capabilities_path(&name, dir);

if !caps_path.exists() {
anyhow::bail!(
"Tool '{}' not found or has no capabilities file at {}",
"Tool '{}' not found or has no capabilities file at {} (or {} for WASM channels)",
name,
caps_path.display()
caps_path.display(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

High: combine_provider_scopes(&tools_dir, ...) is called with tools_dir = dir.clone().unwrap_or_else(default_tools_dir) — still ~/.ironclaw/tools by default. But when the capabilities file was resolved from ~/.ironclaw/channels/ via the new fallback, this scan goes against the wrong directory. The OAuth-scope consolidation silently misses sibling channel caps that share the same secret_name, so a user running ironclaw tool auth telegram on a channel-resolved caps file still gets a tools-dir-only scope merge. Either switch the scan to caps_path.parent() (the directory the caps actually came from), or scan both dirs. A caller-level test on auth_tool — per .claude/rules/testing.md ("Test Through the Caller, Not Just the Helper") — would have caught this: the helper is tested but its sole non-trivial downstream consumer is not.

default_channels_dir()
.join(format!("{}.capabilities.json", name))
.display()
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Medium: Clap help text drift. The --dir flags on Auth and Setup still say "Directory to look for tool (default: ~/.ironclaw/tools/)" (lines 94-96 and 108-110, unchanged by this PR). After this change the actual default precedence is channels-first, tools-fallback. Please update the help strings so ironclaw tool auth --help / ironclaw tool setup --help reflect the new behavior (e.g. "default: ~/.ironclaw/channels/, then ~/.ironclaw/tools/"). Same drift applies to the Setup variant.

}

Expand Down Expand Up @@ -1016,14 +1054,16 @@ fn print_success(display_name: &str) {
/// Configure required secrets for a tool via its `setup.required_secrets` schema.
async fn setup_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
validate_tool_name(&name)?;
let tools_dir = dir.unwrap_or_else(default_tools_dir);
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
let caps_path = resolve_capabilities_path(&name, dir);

if !caps_path.exists() {
anyhow::bail!(
"Tool '{}' not found or has no capabilities file at {}",
"Tool '{}' not found or has no capabilities file at {} (or {} for WASM channels)",
name,
caps_path.display()
caps_path.display(),
default_channels_dir()
.join(format!("{}.capabilities.json", name))
.display()
);
}

Expand Down Expand Up @@ -1143,4 +1183,62 @@ mod tests {
assert!(dir.to_string_lossy().contains(".ironclaw"));
assert!(dir.to_string_lossy().contains("tools"));
}

#[test]
fn test_resolve_capabilities_path_falls_back_to_channels_dir() {
let base =
std::env::temp_dir().join(format!("ironclaw-tool-auth-test-{}", uuid::Uuid::new_v4()));
let tools_dir = base.join("tools");
let channels_dir = base.join("channels");
assert!(
std::fs::create_dir_all(&tools_dir).is_ok(),
"create tools dir"
);
assert!(
std::fs::create_dir_all(&channels_dir).is_ok(),
"create channels dir"
);

let caps_path = channels_dir.join("telegram.capabilities.json");
assert!(std::fs::write(&caps_path, "{}").is_ok(), "write caps");

let resolved = resolve_capabilities_path_in_base(&base, "telegram", None);
assert_eq!(resolved, caps_path);

let _ = std::fs::remove_dir_all(base);
}

#[test]
fn test_resolve_capabilities_path_prefers_channels_dir_on_collision() {
let base = std::env::temp_dir().join(format!(
"ironclaw-tool-auth-test-collision-{}",
uuid::Uuid::new_v4()
));
let tools_dir = base.join("tools");
let channels_dir = base.join("channels");
assert!(
std::fs::create_dir_all(&tools_dir).is_ok(),
"create tools dir"
);
assert!(
std::fs::create_dir_all(&channels_dir).is_ok(),
"create channels dir"
);

let tool_caps = tools_dir.join("telegram.capabilities.json");
let channel_caps = channels_dir.join("telegram.capabilities.json");
assert!(
std::fs::write(&tool_caps, "{\"kind\":\"tool\"}").is_ok(),
"write tool caps"
);
assert!(
std::fs::write(&channel_caps, "{\"kind\":\"channel\"}").is_ok(),
"write channel caps"
);

let resolved = resolve_capabilities_path_in_base(&base, "telegram", None);
assert_eq!(resolved, channel_caps);

let _ = std::fs::remove_dir_all(base);
}
}
Loading