mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(extensions): unify auth and configure into single entrypoint Refactors the extension lifecycle to eliminate the divergence between chat and gateway paths that caused Telegram setup via chat to fail (missing webhook secret auto-generation, no token validation). Key changes: - Rename save_setup_secrets() → configure(): single entrypoint for providing secrets to any extension (WasmChannel, WasmTool, MCP). Validates, stores, auto-generates, and activates. - Add configure_token(): convenience wrapper for single-token callers (chat auth card, WebSocket, agent auth mode). - Refactor auth() to pure status check: remove token parameter, delete token-storing branches from auth_mcp/auth_wasm_tool, rename auth_wasm_channel → auth_wasm_channel_status. - Add ConfigureResult/MissingSecret types for structured responses. - Replace hardcoded Telegram token validation with generic validation_endpoint from capabilities.json. - Update all callers (9 files) to use the new interface. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use ValidationFailed error variant instead of string matching Replace brittle msg.contains("Invalid token") checks with a proper ExtensionError::ValidationFailed variant. configure() now returns this variant for token validation failures, and callers match on it directly instead of parsing error message strings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — SSRF protection, error typing, missing-secret selection, WS auth 1. SSRF: call validate_fetch_url() before validation_endpoint HTTP request 2. Transport errors map to ExtensionError::Other (not ValidationFailed) 3. configure_token() picks first *missing* secret, not first non-optional 4. WebSocket error path re-emits AuthRequired on ValidationFailed Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test: add regression tests for extension lifecycle refactoring - test_configure_token_picks_first_missing_secret: verifies multi-secret channels can be configured one secret at a time (commit ce106f4) - test_auth_is_read_only_for_wasm_channel: verifies auth() has no side effects and doesn't store secrets (commit 47f8eb6) - test_validation_failed_is_distinct_error_variant: verifies the typed error variant can be pattern-matched (commit a318161) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments — activation dispatch, dead code, caps consolidation - Fix configure() fallthrough bug: dispatch activation by ExtensionKind instead of unconditionally calling activate_wasm_channel() for all non-WasmTool types (MCP servers and channel relays now use their correct activation methods) - Remove dead MissingSecret struct and missing_secrets field (never populated, flagged by reviewer) - Consolidate capabilities file parsing in configure(): parse once and reuse for allowed names, validation_endpoint, and auto-generation - Fix auth() doc comment: note MCP OAuth side effects - Fix stale save_setup_secrets reference in server.rs comment - Add regression test for activation dispatch bug Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
794 lines
25 KiB
Rust
794 lines
25 KiB
Rust
//! Agent-callable tools for managing extensions (MCP servers and WASM tools).
|
|
//!
|
|
//! These six tools let the LLM search, install, authenticate, activate, list,
|
|
//! and remove extensions entirely through conversation.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use crate::context::JobContext;
|
|
use crate::extensions::{ExtensionKind, ExtensionManager};
|
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
|
|
|
// ── tool_search ──────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolSearchTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolSearchTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolSearchTool {
|
|
fn name(&self) -> &str {
|
|
"tool_search"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Search for available extensions to add new capabilities. Extensions include \
|
|
channels (Telegram, Slack, Discord — for messaging), tools, and MCP servers. \
|
|
Use discover:true to search online if the built-in registry has no results."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Search query (name, keyword, or description fragment)"
|
|
},
|
|
"discover": {
|
|
"type": "boolean",
|
|
"description": "If true, also search online (slower, 5-15s). Try without first.",
|
|
"default": false
|
|
}
|
|
},
|
|
"required": ["query"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
|
let discover = params
|
|
.get("discover")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let results = self
|
|
.manager
|
|
.search(query, discover)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
let output = serde_json::json!({
|
|
"results": results,
|
|
"count": results.len(),
|
|
"searched_online": discover,
|
|
});
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
}
|
|
|
|
// ── tool_install ─────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolInstallTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolInstallTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolInstallTool {
|
|
fn name(&self) -> &str {
|
|
"tool_install"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Install an extension (channel, tool, or MCP server). \
|
|
Use the name from tool_search results, or provide an explicit URL."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name (from search results or custom)"
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "Explicit URL (for extensions not in the registry)"
|
|
},
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
|
|
"description": "Extension type (auto-detected if omitted)"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = require_str(¶ms, "name")?;
|
|
|
|
let url = params.get("url").and_then(|v| v.as_str());
|
|
|
|
let kind_hint = params
|
|
.get("kind")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|k| match k {
|
|
"mcp_server" => Some(ExtensionKind::McpServer),
|
|
"wasm_tool" => Some(ExtensionKind::WasmTool),
|
|
"wasm_channel" => Some(ExtensionKind::WasmChannel),
|
|
_ => None,
|
|
});
|
|
|
|
let result = self
|
|
.manager
|
|
.install(name, url, kind_hint)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
let output = serde_json::to_value(&result)
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
}
|
|
|
|
// ── tool_auth ────────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolAuthTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolAuthTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolAuthTool {
|
|
fn name(&self) -> &str {
|
|
"tool_auth"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Initiate authentication for an extension. For OAuth, returns a URL. \
|
|
For manual auth, returns instructions. The user provides their token \
|
|
through a secure channel, never through this tool."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name to authenticate"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = require_str(¶ms, "name")?;
|
|
|
|
let result = self
|
|
.manager
|
|
.auth(name)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
// Auto-activate after successful auth so tools are available immediately
|
|
if result.is_authenticated() {
|
|
match self.manager.activate(name).await {
|
|
Ok(activate_result) => {
|
|
let output = serde_json::json!({
|
|
"status": "authenticated_and_activated",
|
|
"name": name,
|
|
"tools_loaded": activate_result.tools_loaded,
|
|
"message": activate_result.message,
|
|
});
|
|
return Ok(ToolOutput::success(output, start.elapsed()));
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Extension '{}' authenticated but activation failed: {}",
|
|
name,
|
|
e
|
|
);
|
|
let output = serde_json::json!({
|
|
"status": "authenticated",
|
|
"name": name,
|
|
"activation_error": e.to_string(),
|
|
"message": format!(
|
|
"Authenticated but activation failed: {}. Try tool_activate.",
|
|
e
|
|
),
|
|
});
|
|
return Ok(ToolOutput::success(output, start.elapsed()));
|
|
}
|
|
}
|
|
}
|
|
|
|
let output = serde_json::to_value(&result)
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
}
|
|
|
|
// ── tool_activate ────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolActivateTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolActivateTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolActivateTool {
|
|
fn name(&self) -> &str {
|
|
"tool_activate"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Activate an installed extension — starts channels, loads tools, or connects to MCP servers."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name to activate"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = require_str(¶ms, "name")?;
|
|
|
|
match self.manager.activate(name).await {
|
|
Ok(result) => {
|
|
let output = serde_json::to_value(&result)
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
Err(activate_err) => {
|
|
let err_str = activate_err.to_string();
|
|
let needs_auth = err_str.contains("authentication")
|
|
|| err_str.contains("401")
|
|
|| err_str.contains("Unauthorized")
|
|
|| err_str.contains("not authenticated");
|
|
|
|
if !needs_auth {
|
|
return Err(ToolError::ExecutionFailed(err_str));
|
|
}
|
|
|
|
// Activation failed due to missing auth; initiate auth flow
|
|
// so the agent loop can show the auth card.
|
|
match self.manager.auth(name).await {
|
|
Ok(auth_result) if auth_result.is_authenticated() => {
|
|
// Auth succeeded (e.g. env var was set); retry activation.
|
|
let result = self
|
|
.manager
|
|
.activate(name)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
let output = serde_json::to_value(&result).unwrap_or_else(
|
|
|_| serde_json::json!({"error": "serialization failed"}),
|
|
);
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
Ok(auth_result) => {
|
|
// Auth needs user input (awaiting_token). Return the auth
|
|
// result so detect_auth_awaiting picks it up.
|
|
let output = serde_json::to_value(&auth_result).unwrap_or_else(
|
|
|_| serde_json::json!({"error": "serialization failed"}),
|
|
);
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
Err(auth_err) => Err(ToolError::ExecutionFailed(format!(
|
|
"Activation failed ({}), and authentication also failed: {}",
|
|
err_str, auth_err
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── tool_list ────────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolListTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolListTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolListTool {
|
|
fn name(&self) -> &str {
|
|
"tool_list"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"List extensions with their authentication and activation status. \
|
|
Set include_available:true to also show registry entries not yet installed."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
|
|
"description": "Filter by extension type (omit to list all)"
|
|
},
|
|
"include_available": {
|
|
"type": "boolean",
|
|
"description": "If true, also include registry entries that are not yet installed",
|
|
"default": false
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let kind_filter = params
|
|
.get("kind")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|k| match k {
|
|
"mcp_server" => Some(ExtensionKind::McpServer),
|
|
"wasm_tool" => Some(ExtensionKind::WasmTool),
|
|
"wasm_channel" => Some(ExtensionKind::WasmChannel),
|
|
_ => None,
|
|
});
|
|
|
|
let include_available = params
|
|
.get("include_available")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let extensions = self
|
|
.manager
|
|
.list(kind_filter, include_available)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
let output = serde_json::json!({
|
|
"extensions": extensions,
|
|
"count": extensions.len(),
|
|
});
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
}
|
|
|
|
// ── tool_remove ──────────────────────────────────────────────────────────
|
|
|
|
pub struct ToolRemoveTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolRemoveTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolRemoveTool {
|
|
fn name(&self) -> &str {
|
|
"tool_remove"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \
|
|
This action cannot be undone — the WASM binary and configuration files will be deleted."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name to remove"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = require_str(¶ms, "name")?;
|
|
|
|
let message = self
|
|
.manager
|
|
.remove(name)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
let output = serde_json::json!({
|
|
"name": name,
|
|
"message": message,
|
|
});
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::Always
|
|
}
|
|
}
|
|
|
|
// ── tool_upgrade ─────────────────────────────────────────────────────
|
|
|
|
pub struct ToolUpgradeTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ToolUpgradeTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ToolUpgradeTool {
|
|
fn name(&self) -> &str {
|
|
"tool_upgrade"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Upgrade installed WASM extensions (channels and tools) to match the current \
|
|
host WIT version. If name is omitted, checks and upgrades all installed WASM \
|
|
extensions. Authentication and secrets are preserved."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name to upgrade (omit to upgrade all)"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = params.get("name").and_then(|v| v.as_str());
|
|
|
|
let result = self
|
|
.manager
|
|
.upgrade(name)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
let output = serde_json::to_value(&result)
|
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}));
|
|
|
|
Ok(ToolOutput::success(output, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
}
|
|
|
|
// ── extension_info ────────────────────────────────────────────────────
|
|
|
|
pub struct ExtensionInfoTool {
|
|
manager: Arc<ExtensionManager>,
|
|
}
|
|
|
|
impl ExtensionInfoTool {
|
|
pub fn new(manager: Arc<ExtensionManager>) -> Self {
|
|
Self { manager }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ExtensionInfoTool {
|
|
fn name(&self) -> &str {
|
|
"extension_info"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Show detailed information about an installed extension, including version \
|
|
and WIT version compatibility."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Extension name to get info about"
|
|
}
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let start = std::time::Instant::now();
|
|
|
|
let name = require_str(¶ms, "name")?;
|
|
|
|
let info = self
|
|
.manager
|
|
.extension_info(name)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
|
|
|
Ok(ToolOutput::success(info, start.elapsed()))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_tool_search_schema() {
|
|
let tool = ToolSearchTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_search");
|
|
let schema = tool.parameters_schema();
|
|
assert!(schema.get("properties").is_some());
|
|
assert!(schema["properties"].get("query").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_install_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolInstallTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_install");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
);
|
|
let schema = tool.parameters_schema();
|
|
assert!(schema["properties"].get("name").is_some());
|
|
assert!(schema["properties"].get("url").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_auth_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolAuthTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_auth");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
);
|
|
let schema = tool.parameters_schema();
|
|
assert!(schema["properties"].get("name").is_some());
|
|
// token param must NOT be in schema (security: tokens never go through LLM)
|
|
assert!(
|
|
schema["properties"].get("token").is_none(),
|
|
"tool_auth must not have a token parameter"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_activate_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolActivateTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_activate");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::Never
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_list_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolListTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_list");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::Never
|
|
);
|
|
let schema = tool.parameters_schema();
|
|
assert!(schema["properties"].get("kind").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_remove_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolRemoveTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_remove");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::Always
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_remove_always_requires_approval_regardless_of_params() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolRemoveTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
|
|
let test_cases = vec![
|
|
("no params", serde_json::json!({})),
|
|
("empty name", serde_json::json!({"name": ""})),
|
|
("slack", serde_json::json!({"name": "slack"})),
|
|
("github-cli", serde_json::json!({"name": "github-cli"})),
|
|
(
|
|
"with extra fields",
|
|
serde_json::json!({"name": "tool", "extra": "field"}),
|
|
),
|
|
];
|
|
|
|
for (case_name, params) in test_cases {
|
|
assert_eq!(
|
|
tool.requires_approval(¶ms),
|
|
ApprovalRequirement::Always,
|
|
"tool_remove must always require approval for case: {}",
|
|
case_name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_upgrade_schema() {
|
|
use crate::tools::tool::ApprovalRequirement;
|
|
let tool = ToolUpgradeTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "tool_upgrade");
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
);
|
|
let schema = tool.parameters_schema();
|
|
// name is optional (omit to upgrade all)
|
|
assert!(schema["properties"].get("name").is_some());
|
|
assert!(
|
|
schema.get("required").is_none(),
|
|
"tool_upgrade should have no required params"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extension_info_schema() {
|
|
let tool = ExtensionInfoTool {
|
|
manager: test_manager_stub(),
|
|
};
|
|
assert_eq!(tool.name(), "extension_info");
|
|
let schema = tool.parameters_schema();
|
|
assert!(schema["properties"].get("name").is_some());
|
|
let required = schema["required"].as_array().unwrap();
|
|
assert!(required.iter().any(|v| v.as_str() == Some("name")));
|
|
}
|
|
|
|
/// Create a stub manager for schema tests (these don't call execute).
|
|
fn test_manager_stub() -> Arc<ExtensionManager> {
|
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
|
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::tools::mcp::session::McpSessionManager;
|
|
|
|
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
|
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
|
|
|
Arc::new(ExtensionManager::new(
|
|
Arc::new(McpSessionManager::new()),
|
|
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
|
Arc::new(InMemorySecretsStore::new(crypto)),
|
|
Arc::new(ToolRegistry::new()),
|
|
None,
|
|
None,
|
|
std::env::temp_dir().join("ironclaw-test-tools"),
|
|
std::env::temp_dir().join("ironclaw-test-channels"),
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
Vec::new(),
|
|
))
|
|
}
|
|
}
|