mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: multi-tenant auth with per-user scoping Multi-user authentication and authorization for IronClaw gateway: - Token-based auth mapping tokens to user IDs via GATEWAY_USER_TOKENS - Per-user SSE broadcast scoping - Per-user rate limiting with poisoned lock recovery - Handler auth and ownership checks for jobs, settings, routines - Extension secrets scoped per-user - Chat handlers use authenticated identity - Reverse proxy deployment documentation - Comprehensive integration tests for auth, SSE, rate limiting, and job isolation * fix: scope memory tools per-user in multi-tenant mode Memory tools (search, write, read, tree) held a single workspace created at startup with GATEWAY_USER_ID. In multi-tenant mode, all users' tool calls searched the default user's scope. Add WorkspaceResolver trait that resolves workspaces per-request using JobContext.user_id. In single-user mode, returns the startup workspace. In multi-tenant mode (GATEWAY_USER_TOKENS configured), creates and caches per-user workspaces on demand. Includes regression tests for workspace resolution and user isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: comprehensive multi-tenant isolation audit Address all review findings from @serrrfirat plus 7 additional gaps found via full security audit: Reviewer findings (5): - WorkspacePool now applies search config, memory layers, embedding cache, identity read scopes, and global config scopes (was bare) - jobs_summary_handler uses per-user queries instead of global counters - jobs_prompt_handler restructured to not 404 agent jobs + ownership check - jobs_restart_handler agent branch now verifies user ownership - agent_job_summary_for_user added to Database trait + both backends Audit findings (7): - Delete dead handlers/memory.rs (stale copies with no auth) - Add AuthenticatedUser to logs_events, logs_level_get, logs_level_set - Add AuthenticatedUser to extensions_tools_handler, gateway_status_handler - Add auth + ownership checks to all 6 routines handlers - Add auth to all 4 skills handlers with audit logging on mutations - Scope extension setup SSE broadcast to user (broadcast_for_user) - Fix pre-existing test compilation errors in extensions/manager.rs 17 new multi-tenant isolation tests covering: - WorkspacePool config propagation and scope merging - Jobs handler per-user isolation (summary, restart, prompt, cancel) - Routines handler auth enforcement and cross-user rejection - Auth middleware enforcement on logs, skills, status endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: second-pass multi-tenant audit — scope SSE broadcasts, DB queries, dead handlers Second audit pass applying learned patterns across the codebase: - OAuth callback SSE broadcasts now use broadcast_for_user (lines 773, 912) - jobs_list_handler uses list_agent_jobs_for_user instead of fetching all users' jobs and filtering in Rust - list_agent_jobs_for_user added to Database trait + postgres + libsql - Dead handler files (extensions.rs, static_files.rs) hardened with AuthenticatedUser to prevent auth regression if migrated Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — token hashing, broadcast scoping, error handling Security fixes: - Hash tokens with SHA-256 at construction time so authentication compares fixed-size 32-byte digests, eliminating length-oracle timing leaks - Scope auth SSE broadcasts per-user in chat_auth_token_handler — AuthRequired/AuthCompleted events were leaking across tenants - Propagate DB errors in restart handlers instead of silently swallowing via `if let Ok(Some(...))` pattern Code quality: - Log SSE serialization failures instead of silently producing empty strings via unwrap_or_default() - Remove dead `pub type AuthState = MultiAuthState` alias - Replace `.unwrap()` with `Arc::clone(db)` in app.rs multi-tenant workspace setup (db is guaranteed Some in context, but unwrap violates project convention) - Fix telegram setup test to inject UserIdentity into request extensions (handler now requires AuthenticatedUser) - Add safety comments on test-only expect/unwrap calls for CI - Apply cargo fmt to fix pre-existing formatting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review findings — unify workspace pool, fix SSE regression, cache job owners - Unify WorkspacePool and PerUserWorkspaceResolver: WorkspacePool now implements WorkspaceResolver, eliminating duplicate per-user workspace construction logic. app.rs uses WorkspacePool directly. - Fix sse_tx: None scheduler regression: change scheduler/worker SSE broadcasting from broadcast::Sender<SseEvent> to Arc<SseManager>, restoring SSE event delivery for scheduled agent jobs. - Cache job owner in orchestrator: add job_owner_cache to OrchestratorState so job_event_handler avoids a DB round-trip on every event after the first per job. - Deduplicate ext_user_id computation in main.rs. - Remove unused _gateway_state variable. - Fix pre-existing test: first_token() returns None in multi-user mode by design; align test assertion. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix formatting in app.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: extract memory handlers back into handlers/memory.rs Move memory API handlers out of server.rs into their own module, consistent with how jobs, routines, and skills handlers are organized. The resolve_workspace() helper moves with them since it is only used by memory handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: [email protected] <[email protected]>
816 lines
26 KiB
Rust
816 lines
26 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, &ctx.user_id)
|
|
.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, &ctx.user_id)
|
|
.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, &ctx.user_id).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 {
|
|
// In gateway mode, tool_auth only returns an auth URL for the frontend
|
|
// to open — no browser is launched server-side, so no approval needed.
|
|
if self.manager.should_use_gateway_mode() {
|
|
ApprovalRequirement::Never
|
|
} else {
|
|
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, &ctx.user_id).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, &ctx.user_id).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, &ctx.user_id)
|
|
.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, &ctx.user_id)
|
|
.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, &ctx.user_id)
|
|
.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, &ctx.user_id)
|
|
.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, &ctx.user_id)
|
|
.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
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_auth_no_approval_in_gateway_mode() {
|
|
let manager = test_manager_stub();
|
|
manager
|
|
.enable_gateway_mode("http://localhost:3000".to_string())
|
|
.await;
|
|
let tool = ToolAuthTool {
|
|
manager: manager.clone(),
|
|
};
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({})),
|
|
ApprovalRequirement::Never,
|
|
"tool_auth should not require approval in gateway mode"
|
|
);
|
|
}
|
|
|
|
#[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(),
|
|
))
|
|
}
|
|
}
|