From 8dfedfa5fb2dbf7d315ea15e941d0e61c461a106 Mon Sep 17 00:00:00 2001 From: Vu Tran Date: Fri, 10 Apr 2026 21:53:13 +0700 Subject: [PATCH] feat: add native Composio tool for third-party app integrations (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Composio WASM tool for third-party app integrations Add Composio integration as a WASM tool (tools-src/composio/), providing a single multiplexed tool with 4 actions: list, execute, connect, and connected_accounts. Supports 250+ third-party apps via Composio's REST API with WASM sandbox security (fuel metering, memory limits, network allowlisting, host-injected credentials). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review — retry safety, dead code, registry manifest Code fixes (tools-src/composio/src/lib.rs): - Only retry GET requests (idempotent); POST executes once to prevent duplicate side effects on execute/connect actions - Remove dead parse_json_response status check (already handled by caller); use serde_json::from_slice to avoid extra allocation - Remove misleading secret_exists pre-flight (only checks capability allowlist, not actual presence); instead surface helpful error on 401/403 from the API - Extract entity_id logic into extract_entity_id() helper with 6 unit tests covering precedence chain and edge cases Registry: - Add registry/tools/composio.json manifest (matches format of other tools like web-search, github, gmail) - Add composio to the default bundle in _bundles.json Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore secret pre-flight, enforce schema, remove default tag - Restore secret_exists pre-flight as best-effort check (avoids wasting rate-limited API calls when clearly misconfigured) - Add #[serde(deny_unknown_fields)] to Params to match the schema's additionalProperties: false contract - Remove "default" tag from registry manifest and remove from default bundle until WASM artifacts are published Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify entity_id fallback, add params type to schema - Remove requester_id fallback from extract_entity_id (user_id is always present in JobContext, so requester_id was dead code) - Add "type": "object" to params field in both tool schema and capabilities.json to prevent schema-driven callers from sending non-object values Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align with Composio v3 API contract + fixture tests Address serrrfirat's review — update all response parsing and request fields to match the current Composio v3 API: - Add unwrap_items() helper for paginated { "items": [...] } envelopes, with bare-array fallback for backward compatibility - connect_app: parse auth_configs from paginated response via extract_auth_config_id() - execute_action: use v3 fields `user_id` + `arguments` (not deprecated `entity_id` + `input`) - list_accounts/resolve_account: use plural query params `user_ids`, `toolkit_slugs` (v3 contract) - lookup_app_for_tool: look for nested `toolkit.slug` (v3), falling back to `toolkit_slug` and `appName` - find_active_account: sort by `updated_at` (v3), falling back to `updatedAt` Add 15 fixture-style tests covering: - Paginated envelope parsing (envelope, bare array, empty, non-array) - Auth config extraction (paginated, bare, empty) - Toolkit slug extraction (v3 nested, legacy flat, appName fallback, case-insensitive, not-found) - Active account selection (v3 timestamps, legacy timestamps, no active) Total: 25 tests (5 URL, 5 entity_id, 15 v3 contract fixtures) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review — remove duplicate parameters, validate params, fix ordering - Remove `parameters` section from capabilities JSON (duplicates SCHEMA const, runtime ignores it, creates drift risk) - Fix Cargo.toml exclude ordering: tools-src/composio before tools-src/github - Validate `params` is a JSON object when provided, reject non-object values early - Remove 429 from retry logic (WASM has no sleep/backoff, immediate retry wastes rate-limit budget) — only retry on transient 5xx - Add tests for params validation Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address maintainer review — retry convention, numeric IDs, slug validation - Revert 429 retry to align with github/web-search tool convention (sub-second sliding-window resets can make immediate retries worthwhile) - Handle numeric entity_id/user_id in context JSON (as_u64/as_i64 fallback) - Add validate_tool_slug() defense-in-depth against path traversal (same pattern as github tool) - Add tests for numeric entity IDs and slug validation (32 total) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address 4 unresolved audit issues — pagination, direct lookup, array params 1. list_tools: expose cursor/limit params in schema, preserve next_cursor and total in response for multi-page browsing, add toolkit_versions=latest 2. lookup_app_for_tool: use direct GET /tools/{slug} endpoint instead of fuzzy search (avoids false negatives from search pagination/ranking), add toolkit_versions=latest 3. connected_accounts queries: encode user_ids and toolkit_slugs as array params (user_ids[], toolkit_slugs[]) per v3 API contract 4. toolkit_versions=latest added to both list and lookup endpoints Adds 5 new tests (37 total): cursor/limit params, array query encoding, direct tool response parsing (v3 nested, legacy, missing). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: ilblackdragon@gmail.com Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: firat.sertgoz --- Cargo.toml | 1 + registry/tools/composio.json | 33 + tools-src/composio/Cargo.toml | 23 + .../composio/composio-tool.capabilities.json | 48 + tools-src/composio/src/lib.rs | 899 ++++++++++++++++++ 5 files changed, 1004 insertions(+) create mode 100644 registry/tools/composio.json create mode 100644 tools-src/composio/Cargo.toml create mode 100644 tools-src/composio/composio-tool.capabilities.json create mode 100644 tools-src/composio/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cae4bfde29..4b2cb6c6b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ exclude = [ "channels-src/telegram", "channels-src/slack", "channels-src/whatsapp", + "tools-src/composio", "tools-src/github", "tools-src/gmail", "tools-src/google-calendar", diff --git a/registry/tools/composio.json b/registry/tools/composio.json new file mode 100644 index 0000000000..4db3680b92 --- /dev/null +++ b/registry/tools/composio.json @@ -0,0 +1,33 @@ +{ + "name": "composio", + "display_name": "Composio", + "kind": "tool", + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, Jira, etc.) via Composio", + "keywords": [ + "composio", + "integrations", + "oauth", + "automation", + "apps" + ], + "source": { + "dir": "tools-src/composio", + "capabilities": "composio-tool.capabilities.json", + "crate_name": "composio-tool" + }, + "artifacts": {}, + "auth_summary": { + "method": "manual", + "provider": "Composio", + "secrets": [ + "composio_api_key" + ], + "shared_auth": null, + "setup_url": "https://app.composio.dev/" + }, + "tags": [ + "integrations" + ] +} diff --git a/tools-src/composio/Cargo.toml b/tools-src/composio/Cargo.toml new file mode 100644 index 0000000000..5fc9b8c0a0 --- /dev/null +++ b/tools-src/composio/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "composio-tool" +version = "0.1.0" +edition = "2021" +description = "Composio integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/tools-src/composio/composio-tool.capabilities.json b/tools-src/composio/composio-tool.capabilities.json new file mode 100644 index 0000000000..7ce3fab069 --- /dev/null +++ b/tools-src/composio/composio-tool.capabilities.json @@ -0,0 +1,48 @@ +{ + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. Actions: list (browse tools), execute (run a tool), connect (OAuth-link an app), connected_accounts (list linked accounts). Authentication is handled via the 'composio_api_key' secret injected by the host.", + "capabilities": { + "http": { + "allowlist": [ + { + "host": "backend.composio.dev", + "path_prefix": "/api/v3/", + "methods": ["GET", "POST"] + } + ], + "credentials": { + "composio_api_key": { + "secret_name": "composio_api_key", + "location": { + "type": "header", + "name": "x-api-key" + }, + "host_patterns": ["backend.composio.dev"] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + } + }, + "secrets": { + "allowed_names": ["composio_api_key"] + } + }, + "auth": { + "secret_name": "composio_api_key", + "display_name": "Composio", + "instructions": "Get an API key at app.composio.dev — go to Settings > API Keys to generate one.", + "setup_url": "https://app.composio.dev/", + "env_var": "COMPOSIO_API_KEY" + }, + "setup": { + "required_secrets": [ + { + "name": "composio_api_key", + "prompt": "Composio API key (from app.composio.dev)" + } + ] + } +} diff --git a/tools-src/composio/src/lib.rs b/tools-src/composio/src/lib.rs new file mode 100644 index 0000000000..d5107d680f --- /dev/null +++ b/tools-src/composio/src/lib.rs @@ -0,0 +1,899 @@ +//! Composio WASM Tool for IronClaw. +//! +//! Connects to 250+ third-party apps via Composio's REST API (v3). +//! Provides a single multiplexed tool with actions: list, execute, connect, +//! connected_accounts. +//! +//! # Authentication +//! +//! Store your Composio API key: +//! `ironclaw secret set composio_api_key ` +//! +//! Get a key at: https://app.composio.dev/ + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +use serde::Deserialize; + +const API_BASE: &str = "https://backend.composio.dev/api/v3"; +const MAX_RETRIES: u32 = 3; + +struct ComposioTool; + +impl exports::near::agent::tool::Guest for ComposioTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. \ + Actions: \"list\" (browse tools), \"execute\" (run a tool), \ + \"connect\" (OAuth-link an app), \"connected_accounts\" (list linked accounts). \ + Authentication is handled via the 'composio_api_key' secret injected by the host." + .to_string() + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Params { + action: String, + app: Option, + tool_slug: Option, + params: Option, + connected_account_id: Option, + cursor: Option, + limit: Option, +} + +fn execute_inner(params_str: &str, context: Option<&str>) -> Result { + let params: Params = + serde_json::from_str(params_str).map_err(|e| format!("Invalid parameters: {e}"))?; + + if params.action.is_empty() { + return Err("'action' must not be empty".into()); + } + + // Best-effort pre-flight: check if the secret is configured in capabilities. + // This won't catch every case (the host may only check the allowlist), but + // avoids wasting a rate-limited API call when clearly misconfigured. + if !near::agent::host::secret_exists("composio_api_key") { + return Err( + "Composio API key not configured. Set it with: \ + ironclaw secret set composio_api_key . \ + Get a key at: https://app.composio.dev/" + .into(), + ); + } + + let entity_id = extract_entity_id(context); + + // Validate params is an object when provided (schema declares it as such). + if let Some(ref p) = params.params { + if !p.is_object() { + return Err("'params' must be a JSON object".into()); + } + } + + match params.action.as_str() { + "list" => list_tools(params.app.as_deref(), params.cursor.as_deref(), params.limit), + "execute" => { + let tool_slug = params + .tool_slug + .as_deref() + .ok_or("missing 'tool_slug' for execute action")?; + validate_tool_slug(tool_slug)?; + let action_params = params.params.unwrap_or(serde_json::json!({})); + execute_action( + tool_slug, + &action_params, + &entity_id, + params.connected_account_id.as_deref(), + ) + } + "connect" => { + let app = params + .app + .as_deref() + .ok_or("missing 'app' for connect action")?; + connect_app(app, &entity_id) + } + "connected_accounts" => list_accounts(params.app.as_deref(), &entity_id), + other => Err(format!( + "unknown action \"{other}\", expected: list, execute, connect, connected_accounts" + )), + } +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +fn api_get(path: &str, query: &[(&str, &str)]) -> Result { + let url = build_url(path, query); + + let headers = serde_json::json!({ + "Accept": "application/json", + "User-Agent": "IronClaw-Composio-Tool/0.1" + }); + + let response = get_with_retry(&url, &headers.to_string())?; + parse_json_body(&response.body) +} + +fn api_post(path: &str, body: &serde_json::Value) -> Result { + let url = build_url(path, &[]); + + let headers = serde_json::json!({ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "IronClaw-Composio-Tool/0.1" + }); + + let body_bytes = serde_json::to_vec(body).map_err(|e| format!("JSON serialize error: {e}"))?; + + // POST is not idempotent — no retry to avoid duplicate side effects. + let resp = near::agent::host::http_request("POST", &url, &headers.to_string(), Some(&body_bytes), None) + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if resp.status >= 200 && resp.status < 300 { + return parse_json_body(&resp.body); + } + + // Surface helpful message on auth failure + if resp.status == 401 || resp.status == 403 { + return Err( + "Composio API authentication failed. Ensure your API key is set: \ + ironclaw secret set composio_api_key . \ + Get a key at: https://app.composio.dev/" + .into(), + ); + } + + let truncated_bytes = if resp.body.len() > 512 { &resp.body[..512] } else { &resp.body }; + let truncated = String::from_utf8_lossy(truncated_bytes); + Err(format!("Composio API error (HTTP {}): {truncated}", resp.status)) +} + +/// GET with retry on transient errors (429, 5xx). Safe to retry since GET is idempotent. +fn get_with_retry( + url: &str, + headers: &str, +) -> Result { + let mut attempt = 0; + loop { + attempt += 1; + + let resp = near::agent::host::http_request("GET", url, headers, None, None) + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if resp.status >= 200 && resp.status < 300 { + return Ok(resp); + } + + // Surface helpful message on auth failure + if resp.status == 401 || resp.status == 403 { + return Err( + "Composio API authentication failed. Ensure your API key is set: \ + ironclaw secret set composio_api_key . \ + Get a key at: https://app.composio.dev/" + .into(), + ); + } + + // Retry on 429 and 5xx to align with github/web-search tool convention. + // NOTE: The WASM host has no sleep primitive, so retries are immediate. + // This still helps when a sliding-window rate limiter resets between + // the original request and the retry (sub-second windows are common). + if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "Composio API error {} (attempt {}/{}). Retrying...", + resp.status, attempt, MAX_RETRIES + ), + ); + continue; + } + + // Truncate at byte level before UTF-8 conversion to avoid + // panicking on multibyte character boundaries. + let truncated_bytes = if resp.body.len() > 512 { + &resp.body[..512] + } else { + &resp.body + }; + let truncated = String::from_utf8_lossy(truncated_bytes); + return Err(format!("Composio API error (HTTP {}): {truncated}", resp.status)); + } +} + +/// Parse a JSON response body directly from bytes (avoids extra allocation). +fn parse_json_body(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|e| format!("invalid JSON: {e}")) +} + +/// Unwrap a paginated v3 response. +/// +/// The Composio v3 API returns paginated results as `{ "items": [...] }`. +/// This helper extracts the `items` array, falling back to treating the +/// response as a bare array for backward compatibility. +fn unwrap_items(value: &serde_json::Value) -> Option<&Vec> { + // v3 paginated envelope: { "items": [...] } + value + .get("items") + .and_then(|v| v.as_array()) + // Fallback: bare array (older or non-paginated endpoints) + .or_else(|| value.as_array()) +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +fn list_tools(app: Option<&str>, cursor: Option<&str>, limit: Option) -> Result { + let limit_str = limit.map(|l| l.to_string()); + let mut query: Vec<(&str, &str)> = vec![("toolkit_versions", "latest")]; + if let Some(a) = app { + query.push(("toolkit_slug", a)); + } + if let Some(ref c) = cursor { + query.push(("cursor", c)); + } + if let Some(ref l) = limit_str { + query.push(("limit", l)); + } + let result = api_get("/tools", &query)?; + + // Preserve pagination metadata (next_cursor, total) alongside items + // so callers can request subsequent pages. + let items = unwrap_items(&result).cloned().unwrap_or_default(); + let mut output = serde_json::json!({ "items": items }); + if let Some(next_cursor) = result.get("next_cursor").and_then(|v| v.as_str()) { + output["next_cursor"] = serde_json::json!(next_cursor); + } + if let Some(total) = result.get("total").and_then(|v| v.as_u64()) { + output["total"] = serde_json::json!(total); + } + serde_json::to_string(&output).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn execute_action( + tool_slug: &str, + params: &serde_json::Value, + entity_id: &str, + connected_account_id: Option<&str>, +) -> Result { + // Auto-resolve connected account if not provided + let account_id = match connected_account_id { + Some(id) => id.to_string(), + None => resolve_account(tool_slug, entity_id)?, + }; + + // v3 contract: `user_id` (not `entity_id`), `arguments` (not `input`). + let body = serde_json::json!({ + "connected_account_id": account_id, + "user_id": entity_id, + "arguments": params, + }); + let result = api_post(&format!("/tools/execute/{}", url_encode(tool_slug)), &body)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn connect_app(app: &str, entity_id: &str) -> Result { + // Resolve auth config for this app — v3 returns paginated { "items": [...] } + let configs = api_get("/auth_configs", &[("toolkit_slug", app)])?; + let auth_config_id = extract_auth_config_id(&configs, app)?; + + let body = serde_json::json!({ + "auth_config_id": auth_config_id, + "user_id": entity_id, + }); + let result = api_post("/connected_accounts/link", &body)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +/// Extract the first auth config ID from a (possibly paginated) response. +fn extract_auth_config_id(configs: &serde_json::Value, app: &str) -> Result { + unwrap_items(configs) + .and_then(|arr| arr.first()) + .and_then(|c| c.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + format!("no auth config found for {app} — configure it at app.composio.dev") + }) +} + +fn list_accounts(app: Option<&str>, entity_id: &str) -> Result { + // v3 documents these as array-valued params: `user_ids[]`, `toolkit_slugs[]` + let mut query = vec![("user_ids[]", entity_id)]; + if let Some(a) = app { + query.push(("toolkit_slugs[]", a)); + } + let result = api_get("/connected_accounts", &query)?; + let items = unwrap_items(&result).cloned().unwrap_or_default(); + serde_json::to_string(&items).map_err(|e| format!("Failed to serialize output: {e}")) +} + +/// Look up the toolkit/app slug for a tool via the Composio API. +/// +/// Uses the direct `GET /tools/{tool_slug}` endpoint for exact lookup, +/// avoiding false negatives from search pagination/ranking. Falls back +/// to the search endpoint if the direct lookup returns a non-item shape. +fn lookup_app_for_tool(tool_slug: &str) -> Result { + // Direct slug endpoint — exact match, no pagination concerns. + let tool = api_get( + &format!("/tools/{}", url_encode(tool_slug)), + &[("toolkit_versions", "latest")], + )?; + extract_toolkit_slug_from_tool(&tool, tool_slug) +} + +/// Extract the toolkit slug from a single tool object (direct endpoint response). +/// +/// v3 nests the toolkit slug under `toolkit.slug`; falls back to +/// `toolkit_slug` or `appName` for backward compatibility. +fn extract_toolkit_slug_from_tool(tool: &serde_json::Value, tool_slug: &str) -> Result { + tool.get("toolkit") + .and_then(|tk| tk.get("slug")) + .or_else(|| tool.get("toolkit_slug")) + .or_else(|| tool.get("appName")) + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + .ok_or_else(|| { + format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct") + }) +} + +/// Extract the toolkit slug from a paginated tools list response. +/// +/// Scans the items array for an exact slug match (case-insensitive). +fn extract_toolkit_slug(tools: &serde_json::Value, tool_slug: &str) -> Result { + let items = unwrap_items(tools).ok_or_else(|| { + format!("could not determine app for tool \"{tool_slug}\" — unexpected response shape") + })?; + + let tool = items + .iter() + .find(|t| { + t.get("slug") + .and_then(|s| s.as_str()) + .is_some_and(|s| s.eq_ignore_ascii_case(tool_slug)) + }) + .ok_or_else(|| { + format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct") + })?; + + extract_toolkit_slug_from_tool(tool, tool_slug) +} + +/// Auto-resolve connected account for a tool slug. +fn resolve_account(tool_slug: &str, entity_id: &str) -> Result { + let app = lookup_app_for_tool(tool_slug)?; + find_active_account(tool_slug, &app, entity_id) +} + +/// Find the most recently updated active connected account. +/// +/// v3 uses `updated_at` (not `updatedAt`) and returns paginated items. +fn find_active_account(tool_slug: &str, app: &str, entity_id: &str) -> Result { + // v3 documents these as array-valued params + let accounts = api_get( + "/connected_accounts", + &[("user_ids[]", entity_id), ("toolkit_slugs[]", app)], + )?; + + let items = unwrap_items(&accounts).ok_or_else(|| { + format!("no connected account for {app} — use composio with action=\"connect\" first") + })?; + + items + .iter() + .filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE")) + .max_by_key(|a| { + // v3: updated_at; fallback: updatedAt + a.get("updated_at") + .or_else(|| a.get("updatedAt")) + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string() + }) + .and_then(|a| a.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + format!( + "no active connected account for {app} (tool: {tool_slug}) — \ + use composio with action=\"connect\" first" + ) + }) +} + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +/// Defense-in-depth: reject tool slugs that could cause path traversal. +/// The WASM host allowlist already normalises paths and rejects `..` segments, +/// but we validate here too (same pattern as the `github` tool). +fn validate_tool_slug(s: &str) -> Result<(), String> { + if s.is_empty() || s.contains("..") || s.contains('/') || s.contains('\\') { + return Err(format!("invalid tool_slug: \"{s}\"")); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +fn build_url(path: &str, query: &[(&str, &str)]) -> String { + let mut url = format!("{API_BASE}{path}"); + if !query.is_empty() { + url.push('?'); + for (i, (k, v)) in query.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(&url_encode(k)); + url.push('='); + url.push_str(&url_encode(v)); + } + } + url +} + +/// Percent-encode a string for safe use in URL query parameters. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b' ' => out.push_str("%20"), + _ => { + out.push('%'); + out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize])); + out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize])); + } + } + } + out +} + +const SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "execute", "connect", "connected_accounts"], + "description": "Action to perform" + }, + "app": { + "type": "string", + "description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")" + }, + "tool_slug": { + "type": "string", + "description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")" + }, + "params": { + "type": "object", + "description": "Parameters for the tool action (JSON object)", + "additionalProperties": true + }, + "connected_account_id": { + "type": "string", + "description": "Specific connected account ID (auto-resolved if omitted)" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor for list action (from previous response's next_cursor)" + }, + "limit": { + "type": "integer", + "description": "Max items per page for list action (default: API default ~20)" + } + }, + "required": ["action"], + "additionalProperties": false +}"#; + +/// Extract an entity identifier from context JSON. +/// +/// Checks `entity_id` first (explicit override), then `user_id` (from +/// JobContext — always present in production), falling back to "default". +fn extract_entity_id(context: Option<&str>) -> String { + context + .and_then(|ctx| serde_json::from_str::(ctx).ok()) + .and_then(|v| { + v.get("entity_id") + .or_else(|| v.get("user_id")) + .and_then(|e| { + e.as_str() + .map(String::from) + .or_else(|| e.as_u64().map(|n| n.to_string())) + .or_else(|| e.as_i64().map(|n| n.to_string())) + }) + }) + .unwrap_or_else(|| "default".to_string()) +} + +export!(ComposioTool); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_encode() { + assert_eq!(url_encode("hello world"), "hello%20world"); + assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz"); + assert_eq!(url_encode("simple"), "simple"); + } + + #[test] + fn test_url_encode_multibyte() { + assert_eq!(url_encode("café"), "caf%C3%A9"); + } + + #[test] + fn test_build_url_no_query() { + let url = build_url("/tools", &[]); + assert_eq!(url, format!("{API_BASE}/tools")); + } + + #[test] + fn test_build_url_with_query() { + let url = build_url("/tools", &[("toolkit_slug", "gmail"), ("search", "send")]); + assert!(url.starts_with(&format!("{API_BASE}/tools?"))); + assert!(url.contains("toolkit_slug=gmail")); + assert!(url.contains("search=send")); + } + + #[test] + fn test_build_url_encodes_special_chars() { + let url = build_url("/tools", &[("q", "my app+1")]); + assert!(url.contains("q=my%20app%2B1")); + } + + #[test] + fn test_extract_entity_id_from_entity_id() { + let ctx = r#"{"entity_id": "tenant-42", "user_id": "user-1"}"#; + assert_eq!(extract_entity_id(Some(ctx)), "tenant-42"); + } + + #[test] + fn test_extract_entity_id_falls_back_to_user_id() { + let ctx = r#"{"user_id": "user-1"}"#; + assert_eq!(extract_entity_id(Some(ctx)), "user-1"); + } + + #[test] + fn test_extract_entity_id_defaults_when_none() { + assert_eq!(extract_entity_id(None), "default"); + } + + #[test] + fn test_extract_entity_id_defaults_on_empty_context() { + assert_eq!(extract_entity_id(Some("{}")), "default"); + } + + #[test] + fn test_extract_entity_id_defaults_on_malformed_json() { + assert_eq!(extract_entity_id(Some("not json")), "default"); + } + + #[test] + fn test_extract_entity_id_numeric_user_id() { + let ctx = r#"{"user_id": 12345}"#; + assert_eq!(extract_entity_id(Some(ctx)), "12345"); + } + + #[test] + fn test_extract_entity_id_numeric_entity_id() { + let ctx = r#"{"entity_id": 99, "user_id": "user-1"}"#; + assert_eq!(extract_entity_id(Some(ctx)), "99"); + } + + #[test] + fn test_validate_tool_slug_valid() { + assert!(validate_tool_slug("GMAIL_SEND_EMAIL").is_ok()); + assert!(validate_tool_slug("slack-post").is_ok()); + } + + #[test] + fn test_validate_tool_slug_rejects_traversal() { + assert!(validate_tool_slug("..").is_err()); + assert!(validate_tool_slug("foo/../bar").is_err()); + assert!(validate_tool_slug("foo/bar").is_err()); + assert!(validate_tool_slug("foo\\bar").is_err()); + assert!(validate_tool_slug("").is_err()); + } + + #[test] + fn test_params_with_cursor_and_limit() { + let p: Params = serde_json::from_str( + r#"{"action": "list", "cursor": "abc123", "limit": 50}"#, + ) + .unwrap(); + assert_eq!(p.cursor.as_deref(), Some("abc123")); + assert_eq!(p.limit, Some(50)); + } + + #[test] + fn test_array_query_param_encoding() { + let url = build_url("/connected_accounts", &[("user_ids[]", "alice"), ("toolkit_slugs[]", "gmail")]); + assert!(url.contains("user_ids%5B%5D=alice")); + assert!(url.contains("toolkit_slugs%5B%5D=gmail")); + } + + #[test] + fn test_extract_toolkit_slug_from_direct_response() { + let tool: serde_json::Value = serde_json::from_str( + r#"{"slug": "GMAIL_SEND_EMAIL", "toolkit": {"slug": "gmail"}}"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug_from_tool(&tool, "GMAIL_SEND_EMAIL").unwrap(), + "gmail" + ); + } + + #[test] + fn test_extract_toolkit_slug_from_direct_response_legacy() { + let tool: serde_json::Value = serde_json::from_str( + r#"{"slug": "SLACK_POST", "toolkit_slug": "slack"}"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug_from_tool(&tool, "SLACK_POST").unwrap(), + "slack" + ); + } + + #[test] + fn test_extract_toolkit_slug_from_direct_response_missing() { + let tool: serde_json::Value = serde_json::from_str( + r#"{"slug": "UNKNOWN"}"#, + ) + .unwrap(); + assert!(extract_toolkit_slug_from_tool(&tool, "UNKNOWN").is_err()); + } + + #[test] + fn test_params_deserialization_rejects_unknown_fields() { + let result: Result = + serde_json::from_str(r#"{"action": "list", "bogus": true}"#); + assert!(result.is_err()); + } + + #[test] + fn test_params_accepts_valid_object_params() { + let p: Params = + serde_json::from_str(r#"{"action": "execute", "params": {"key": "val"}}"#).unwrap(); + assert!(p.params.unwrap().is_object()); + } + + #[test] + fn test_params_accepts_null_params() { + let p: Params = + serde_json::from_str(r#"{"action": "list"}"#).unwrap(); + assert!(p.params.is_none()); + } + + // ----------------------------------------------------------------------- + // Fixture-style tests for v3 API contract parsing + // ----------------------------------------------------------------------- + + #[test] + fn test_unwrap_items_paginated_envelope() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"id": "1"}, {"id": "2"}], "total": 2, "page": 1}"#, + ) + .unwrap(); + let items = unwrap_items(&resp).unwrap(); + assert_eq!(items.len(), 2); + } + + #[test] + fn test_unwrap_items_bare_array_fallback() { + let resp: serde_json::Value = + serde_json::from_str(r#"[{"id": "1"}, {"id": "2"}]"#).unwrap(); + let items = unwrap_items(&resp).unwrap(); + assert_eq!(items.len(), 2); + } + + #[test] + fn test_unwrap_items_empty_envelope() { + let resp: serde_json::Value = + serde_json::from_str(r#"{"items": [], "total": 0}"#).unwrap(); + let items = unwrap_items(&resp).unwrap(); + assert!(items.is_empty()); + } + + #[test] + fn test_unwrap_items_non_array_returns_none() { + let resp: serde_json::Value = + serde_json::from_str(r#"{"error": "not found"}"#).unwrap(); + assert!(unwrap_items(&resp).is_none()); + } + + #[test] + fn test_extract_auth_config_id_from_paginated() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"id": "ac-123", "type": "oauth2"}]}"#, + ) + .unwrap(); + assert_eq!( + extract_auth_config_id(&resp, "gmail").unwrap(), + "ac-123" + ); + } + + #[test] + fn test_extract_auth_config_id_from_bare_array() { + let resp: serde_json::Value = + serde_json::from_str(r#"[{"id": "ac-456"}]"#).unwrap(); + assert_eq!( + extract_auth_config_id(&resp, "github").unwrap(), + "ac-456" + ); + } + + #[test] + fn test_extract_auth_config_id_empty_items() { + let resp: serde_json::Value = + serde_json::from_str(r#"{"items": []}"#).unwrap(); + let err = extract_auth_config_id(&resp, "slack").unwrap_err(); + assert!(err.contains("no auth config found for slack")); + } + + #[test] + fn test_extract_toolkit_slug_v3_nested() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"slug": "GMAIL_SEND_EMAIL", "toolkit": {"slug": "gmail"}}]}"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug(&resp, "GMAIL_SEND_EMAIL").unwrap(), + "gmail" + ); + } + + #[test] + fn test_extract_toolkit_slug_legacy_flat() { + let resp: serde_json::Value = serde_json::from_str( + r#"[{"slug": "SLACK_POST", "toolkit_slug": "slack"}]"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug(&resp, "SLACK_POST").unwrap(), + "slack" + ); + } + + #[test] + fn test_extract_toolkit_slug_app_name_fallback() { + let resp: serde_json::Value = serde_json::from_str( + r#"[{"slug": "NOTION_CREATE", "appName": "Notion"}]"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug(&resp, "NOTION_CREATE").unwrap(), + "notion" + ); + } + + #[test] + fn test_extract_toolkit_slug_case_insensitive_match() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"slug": "github_create_issue", "toolkit": {"slug": "github"}}]}"#, + ) + .unwrap(); + assert_eq!( + extract_toolkit_slug(&resp, "GITHUB_CREATE_ISSUE").unwrap(), + "github" + ); + } + + #[test] + fn test_extract_toolkit_slug_not_found() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"slug": "OTHER_TOOL", "toolkit": {"slug": "other"}}]}"#, + ) + .unwrap(); + let err = extract_toolkit_slug(&resp, "MISSING_TOOL").unwrap_err(); + assert!(err.contains("MISSING_TOOL")); + } + + #[test] + fn test_find_active_account_v3_response() { + // This tests the parsing logic — the actual API call is mocked by + // testing the helper directly. + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [ + {"id": "old-1", "status": "ACTIVE", "updated_at": "2024-01-01T00:00:00Z"}, + {"id": "new-2", "status": "ACTIVE", "updated_at": "2024-06-15T12:00:00Z"}, + {"id": "disabled-3", "status": "DISABLED", "updated_at": "2024-12-01T00:00:00Z"} + ]}"#, + ) + .unwrap(); + let items = unwrap_items(&resp).unwrap(); + let best = items + .iter() + .filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE")) + .max_by_key(|a| { + a.get("updated_at") + .or_else(|| a.get("updatedAt")) + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string() + }) + .and_then(|a| a.get("id")) + .and_then(|id| id.as_str()); + assert_eq!(best, Some("new-2")); + } + + #[test] + fn test_find_active_account_legacy_updated_at() { + let resp: serde_json::Value = serde_json::from_str( + r#"[ + {"id": "a1", "status": "ACTIVE", "updatedAt": "2024-01-01"}, + {"id": "a2", "status": "ACTIVE", "updatedAt": "2024-06-01"} + ]"#, + ) + .unwrap(); + let items = unwrap_items(&resp).unwrap(); + let best = items + .iter() + .filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE")) + .max_by_key(|a| { + a.get("updated_at") + .or_else(|| a.get("updatedAt")) + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string() + }) + .and_then(|a| a.get("id")) + .and_then(|id| id.as_str()); + assert_eq!(best, Some("a2")); + } + + #[test] + fn test_find_active_account_no_active() { + let resp: serde_json::Value = serde_json::from_str( + r#"{"items": [{"id": "x", "status": "DISABLED", "updated_at": "2024-01-01"}]}"#, + ) + .unwrap(); + let items = unwrap_items(&resp).unwrap(); + let best = items + .iter() + .filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE")) + .max_by_key(|a| { + a.get("updated_at") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string() + }); + assert!(best.is_none()); + } +}