diff --git a/plugins/composio-direct/GUIDE.md b/plugins/composio-direct/GUIDE.md index 1d9c19c..1a1c2a6 100644 --- a/plugins/composio-direct/GUIDE.md +++ b/plugins/composio-direct/GUIDE.md @@ -4,13 +4,14 @@ Use `composio-direct` when a user asks Teleton to work with an external app supp ## Required Setup -The plugin requires the Teleton secret `composio_api_key`. It can also be supplied through `COMPOSIO_DIRECT_COMPOSIO_API_KEY`, with `COMPOSIO_API_KEY` kept as a legacy fallback. If the key is missing, stop and ask the operator to configure it before attempting Composio calls. +The plugin requires the Teleton secret `composio_api_key`, using either a Composio project API key or user API key. It can also be supplied through `COMPOSIO_DIRECT_COMPOSIO_API_KEY`, with `COMPOSIO_API_KEY` kept as a legacy fallback. If the key is missing, stop and ask the operator to configure it before attempting Composio calls. Default runtime settings: | Setting | Default | Use | |---|---:|---| | `base_url` | `https://backend.composio.dev/api/v3.1` | Composio API endpoint | +| `api_key_auth_scheme` | `auto` | API key header mode: `auto`, `project`, or `user` | | `timeout_ms` | `30000` | Default request timeout | | `max_parallel_executions` | `10` | Batch execution concurrency | | `tool_version` | `latest` | Tool execution/schema version | @@ -185,7 +186,7 @@ The plugin returns structured results: For `auth_required`, do not retry blindly. Generate or surface a connection link, wait for user confirmation, then retry. For validation errors, fetch the schema again and correct the parameters. For transient network or 5xx failures, the plugin already retries three times with exponential backoff. -HTTP 401/403 from Composio indicates API key authentication or permission failure, not a missing external app connection. Do not call `composio_auth_link` for those errors; verify the `composio_api_key` project key, its endpoint permissions, and any Composio IP allowlist. +HTTP 401/403 from Composio indicates API key authentication or permission failure, not a missing external app connection. Do not call `composio_auth_link` for those errors; verify the `composio_api_key` key type, `api_key_auth_scheme`, endpoint permissions, and any Composio IP allowlist. ## Security Rules diff --git a/plugins/composio-direct/README.md b/plugins/composio-direct/README.md index 58b247d..11ff7bb 100644 --- a/plugins/composio-direct/README.md +++ b/plugins/composio-direct/README.md @@ -18,7 +18,7 @@ Direct integration with **1000+ Composio automation tools** — no MCP transport ## Setup -1. Get your Composio API key at +1. Get your Composio project or user API key at 2. Set the `composio_api_key` secret in Teleton: ```text @@ -27,11 +27,14 @@ Direct integration with **1000+ Composio automation tools** — no MCP transport For container and CI deployments, Teleton also resolves the secret from `COMPOSIO_DIRECT_COMPOSIO_API_KEY`. The plugin keeps `COMPOSIO_API_KEY` as a direct fallback for older deployments. +By default `api_key_auth_scheme` is `auto`: the plugin sends the key as a project key (`x-api-key`) first and, for endpoints that accept user API keys, retries as `x-user-api-key` on Composio 401/403 responses. Set it to `project` or `user` only when you want to force a specific header. + ```yaml # config.yaml example plugins: composio_direct: base_url: "https://backend.composio.dev/api/v3.1" # optional + api_key_auth_scheme: "auto" # optional (auto/project/user) timeout_ms: 30000 # optional (default: 30s) max_parallel_executions: 10 # optional (default: 10) tool_version: "latest" # optional @@ -470,4 +473,4 @@ node --test plugins/composio-direct/test/unit/composio-direct.test.js \ - Added Triggers API coverage through trigger type discovery, active trigger listing, trigger upsert, enable/disable, and delete endpoints. - Added Webhooks API coverage through event type discovery and webhook subscription CRUD/secret rotation endpoints. - Meta-tool alignment: `composio_search_tools`, `composio_get_tool_schemas`, `composio_multi_execute`, connection/auth tools, `composio_manage_connections`, `composio_remote_bash`, and `composio_remote_workbench` cover the practical `search_tools`, `get_tool_schemas`, `multi_execute_tool`, `manage_connections`, `remote_bash_tool`, and `remote_workbench` flows for Teleton. -- HTTP 401/403 responses are reported as Composio API key access failures, not as `auth_required` service authorization. Check the project API key permissions and any Composio IP allowlist before retrying. +- HTTP 401/403 responses are reported as Composio API key access failures, not as `auth_required` service authorization. Check the project/user key type, `api_key_auth_scheme`, endpoint permissions, and any Composio IP allowlist before retrying. diff --git a/plugins/composio-direct/index.js b/plugins/composio-direct/index.js index 75eb369..860dd7e 100644 --- a/plugins/composio-direct/index.js +++ b/plugins/composio-direct/index.js @@ -20,6 +20,7 @@ * * Authentication: * - Requires a Composio API key stored in sdk.secrets as "composio_api_key" + * - Supports project API keys (x-api-key) and user API keys (x-user-api-key) * - Set COMPOSIO_DIRECT_COMPOSIO_API_KEY, COMPOSIO_API_KEY, or use the secrets store * * Transport: @@ -47,6 +48,7 @@ const DEFAULT_BASE_URL = "https://backend.composio.dev/api/v3.1"; const DEFAULT_TOOL_VERSION = "latest"; const DEFAULT_TOOLKIT_VERSIONS = "latest"; +const DEFAULT_API_KEY_AUTH_SCHEME = "auto"; const COMPOSIO_MANAGED_AUTH_UNAVAILABLE_PATTERN = /default auth config not found|does not have managed credentials|managed auth/i; const COMPOSIO_EXECUTION_GUIDANCE = { @@ -64,7 +66,7 @@ const COMPOSIO_EXECUTION_GUIDANCE = { export const manifest = { name: "composio-direct", - version: "1.9.2", + version: "1.9.3", sdkVersion: ">=1.0.0", description: "Direct access to 1000+ Composio automation tools plus v3.1 toolkits, files, triggers, webhooks, connection reuse, and meta-tools without MCP transport", @@ -72,7 +74,8 @@ export const manifest = { composio_api_key: { required: true, env: "COMPOSIO_DIRECT_COMPOSIO_API_KEY", - description: "Composio API key (create at https://app.composio.dev/settings)", + description: + "Composio project or user API key (create at https://app.composio.dev/settings)", }, }, defaultConfig: { @@ -81,6 +84,7 @@ export const manifest = { max_parallel_executions: 10, tool_version: DEFAULT_TOOL_VERSION, toolkit_versions: DEFAULT_TOOLKIT_VERSIONS, + api_key_auth_scheme: DEFAULT_API_KEY_AUTH_SCHEME, auth_config_ids: {}, }, }; @@ -101,15 +105,73 @@ function sleep(ms) { /** * Build common headers for Composio API requests. * @param {string} apiKey + * @param {"project" | "user"} [apiKeyAuthScheme] * @returns {Record} */ -function buildHeaders(apiKey) { +function buildHeaders(apiKey, apiKeyAuthScheme = "project") { + const apiKeyHeader = + apiKeyAuthScheme === "user" ? "x-user-api-key" : "x-api-key"; return { "Content-Type": "application/json", - "x-api-key": apiKey, + [apiKeyHeader]: apiKey, }; } +/** + * Normalize API-key auth scheme config. + * @param {unknown} value + * @returns {"auto" | "project" | "user"} + */ +function normalizeApiKeyAuthScheme(value) { + const scheme = String(value ?? DEFAULT_API_KEY_AUTH_SCHEME) + .trim() + .toLowerCase() + .replace(/_/g, "-"); + + if (scheme === "auto" || scheme === "project" || scheme === "user") { + return scheme; + } + if (scheme === "x-api-key") return "project"; + if (scheme === "x-user-api-key") return "user"; + return DEFAULT_API_KEY_AUTH_SCHEME; +} + +/** + * @param {"project" | "user"} apiKeyAuthScheme + * @returns {"x-api-key" | "x-user-api-key"} + */ +function getApiKeyHeaderName(apiKeyAuthScheme) { + return apiKeyAuthScheme === "user" ? "x-user-api-key" : "x-api-key"; +} + +/** + * Composio v3.1 accepts project keys on x-api-key and user keys on + * x-user-api-key for most user-facing endpoints. In auto mode, try the + * project header first to preserve existing behavior, then retry with the + * user header only when the endpoint supports it and Composio rejected access. + * @param {"auto" | "project" | "user"} apiKeyAuthScheme + * @param {boolean} supportsUserApiKey + * @returns {Array<"project" | "user">} + */ +function getApiKeyAuthAttempts(apiKeyAuthScheme, supportsUserApiKey) { + if (apiKeyAuthScheme === "project") return ["project"]; + if (apiKeyAuthScheme === "user") return ["user"]; + return supportsUserApiKey ? ["project", "user"] : ["project"]; +} + +/** + * When both auto-mode auth headers fail, keep the most actionable API access + * error instead of replacing a clear 403 with a generic 401 from the fallback. + * @param {{ status: number; data: unknown } | null} previous + * @param {{ status: number; data: unknown }} current + * @returns {{ status: number; data: unknown }} + */ +function preferActionableApiAccessError(previous, current) { + if (!previous) return current; + if (previous.status === 403 && current.status === 401) return previous; + return current; +} + /** * Perform an HTTP request with retry logic for network errors and 5xx responses. * Never logs the API key or response bodies containing tokens. @@ -229,14 +291,15 @@ function formatComposioApiAccessError(response) { if (response.status === 403) { return ( "Composio API key permission denied. Check that composio_api_key is a " + - "project API key with permissions for this endpoint and that any Composio " + - `IP allowlist includes this runtime. Composio response: ${message}` + "project or user API key with permissions for this endpoint, that " + + "api_key_auth_scheme matches the key type when set explicitly, and that " + + `any Composio IP allowlist includes this runtime. Composio response: ${message}` ); } return ( "Composio API key was rejected. Check that composio_api_key is a valid " + - `project API key. Composio response: ${message}` + `project or user API key. Composio response: ${message}` ); } @@ -815,6 +878,7 @@ export const tools = (sdk) => { maxParallelExecutions: Number(cfg.max_parallel_executions ?? 10), toolVersion: String(cfg.tool_version ?? DEFAULT_TOOL_VERSION), toolkitVersions: cfg.toolkit_versions ?? DEFAULT_TOOLKIT_VERSIONS, + apiKeyAuthScheme: normalizeApiKeyAuthScheme(cfg.api_key_auth_scheme), authConfigIds: isRecord(cfg.auth_config_ids) ? cfg.auth_config_ids : {}, }; } @@ -841,10 +905,67 @@ export const tools = (sdk) => { return { success: false, error: - "Composio API key is not configured. Please set the composio_api_key secret or COMPOSIO_DIRECT_COMPOSIO_API_KEY with your key from https://app.composio.dev/settings", + "Composio API key is not configured. Please set the composio_api_key secret or COMPOSIO_DIRECT_COMPOSIO_API_KEY with your project or user key from https://app.composio.dev/settings", }; } + /** + * Execute a Composio HTTP request with the configured API-key auth scheme. + * @param {object} opts + * @param {string} opts.apiKey + * @param {string} opts.url + * @param {string} opts.method + * @param {unknown} [opts.body] + * @param {number} opts.timeoutMs + * @param {boolean} [opts.supportsUserApiKey] + * @returns {Promise<{ status: number; data: unknown }>} + */ + async function requestComposio({ + apiKey, + url, + method, + body, + timeoutMs, + supportsUserApiKey = true, + }) { + const { apiKeyAuthScheme } = getConfig(); + const authAttempts = getApiKeyAuthAttempts(apiKeyAuthScheme, supportsUserApiKey); + + let response = null; + let apiAccessErrorResponse = null; + for (let index = 0; index < authAttempts.length; index++) { + const authScheme = authAttempts[index]; + response = await fetchWithRetry({ + url, + method, + headers: buildHeaders(apiKey, authScheme), + body, + timeoutMs, + log: sdk.log, + }); + + const hasNextAuthAttempt = index < authAttempts.length - 1; + if (isComposioApiAccessError(response)) { + apiAccessErrorResponse = preferActionableApiAccessError( + apiAccessErrorResponse, + response + ); + if (hasNextAuthAttempt) { + const nextAuthScheme = authAttempts[index + 1]; + sdk.log.debug( + `composio-direct: HTTP ${response.status} with ${getApiKeyHeaderName(authScheme)}, retrying with ${getApiKeyHeaderName(nextAuthScheme)}` + ); + continue; + } + return apiAccessErrorResponse; + } + + return response; + } + + return response; + } + /** * Execute a Composio v3.1 HTTP request using the plugin defaults. * @param {object} opts @@ -854,17 +975,26 @@ export const tools = (sdk) => { * @param {URLSearchParams} [opts.query] * @param {unknown} [opts.body] * @param {number} [opts.timeoutMs] + * @param {boolean} [opts.supportsUserApiKey] * @returns {Promise<{ status: number; data: unknown }>} */ - function callComposio({ apiKey, path, method = "GET", query, body, timeoutMs }) { + function callComposio({ + apiKey, + path, + method = "GET", + query, + body, + timeoutMs, + supportsUserApiKey = true, + }) { const cfg = getConfig(); - return fetchWithRetry({ + return requestComposio({ + apiKey, url: buildApiUrl(cfg.baseUrl, path, query), method, - headers: buildHeaders(apiKey), body, timeoutMs: timeoutMs ?? cfg.timeoutMs, - log: sdk.log, + supportsUserApiKey, }); } @@ -909,12 +1039,11 @@ export const tools = (sdk) => { is_composio_managed: "true", limit: "1", }); - const listResponse = await fetchWithRetry({ + const listResponse = await requestComposio({ + apiKey, url: `${baseUrl}/auth_configs?${qs.toString()}`, method: "GET", - headers: buildHeaders(apiKey), timeoutMs, - log: sdk.log, }); if (listResponse.status === 200) { @@ -924,10 +1053,10 @@ export const tools = (sdk) => { } if (!resolvedAuthConfigId) { - const createResponse = await fetchWithRetry({ + const createResponse = await requestComposio({ + apiKey, url: `${baseUrl}/auth_configs`, method: "POST", - headers: buildHeaders(apiKey), body: { toolkit: { slug: toolkit }, auth_config: { @@ -937,7 +1066,6 @@ export const tools = (sdk) => { }, }, timeoutMs, - log: sdk.log, }); if (createResponse.status !== 201 && createResponse.status !== 200) { @@ -966,13 +1094,12 @@ export const tools = (sdk) => { if (callbackUrl) linkBody.callback_url = callbackUrl; if (alias) linkBody.alias = alias; - const linkResponse = await fetchWithRetry({ + const linkResponse = await requestComposio({ + apiKey, url: `${baseUrl}/connected_accounts/link`, method: "POST", - headers: buildHeaders(apiKey), body: linkBody, timeoutMs, - log: sdk.log, }); if (linkResponse.status !== 201 && linkResponse.status !== 200) { @@ -1050,12 +1177,11 @@ export const tools = (sdk) => { sdk.log.debug(`composio_search_tools: GET ${url.replace(apiKey, "[REDACTED]")}`); try { - const response = await fetchWithRetry({ + const response = await requestComposio({ + apiKey, url, method: "GET", - headers: buildHeaders(apiKey), timeoutMs, - log: sdk.log, }); if (response.status !== 200) { @@ -1174,12 +1300,11 @@ export const tools = (sdk) => { appendToolkitVersions(qs, toolkitVersions); try { - const response = await fetchWithRetry({ + const response = await requestComposio({ + apiKey, url: `${baseUrl}/tools/${encodeURIComponent(toolSlug)}?${qs.toString()}`, method: "GET", - headers: buildHeaders(apiKey), timeoutMs, - log: sdk.log, }); if (response.status !== 200 || !isRecord(response.data)) { @@ -1312,25 +1437,23 @@ export const tools = (sdk) => { } try { - let response = await fetchWithRetry({ + let response = await requestComposio({ + apiKey, url, method: "POST", - headers: buildHeaders(apiKey), body, timeoutMs: effectiveTimeout, - log: sdk.log, }); const fallbackBaseUrl = getComposioApiFallbackBaseUrl(baseUrl); if (fallbackBaseUrl && isUnknownToolError(response)) { url = `${fallbackBaseUrl}/tools/execute/${encodeURIComponent(normalizedSlug)}`; sdk.log.debug(`composio_execute_tool: retrying ${normalizedSlug} on paired Composio API route`); - response = await fetchWithRetry({ + response = await requestComposio({ + apiKey, url, method: "POST", - headers: buildHeaders(apiKey), body, timeoutMs: effectiveTimeout, - log: sdk.log, }); } @@ -1510,25 +1633,23 @@ export const tools = (sdk) => { } try { - let response = await fetchWithRetry({ + let response = await requestComposio({ + apiKey, url, method: "POST", - headers: buildHeaders(apiKey), body, timeoutMs: effectiveTimeout, - log: sdk.log, }); const fallbackBaseUrl = getComposioApiFallbackBaseUrl(baseUrl); if (fallbackBaseUrl && isUnknownToolError(response)) { url = `${fallbackBaseUrl}/tools/execute/${encodeURIComponent(normalizedSlug)}`; sdk.log.debug(`composio_multi_execute: retrying ${normalizedSlug} on paired Composio API route`); - response = await fetchWithRetry({ + response = await requestComposio({ + apiKey, url, method: "POST", - headers: buildHeaders(apiKey), body, timeoutMs: effectiveTimeout, - log: sdk.log, }); } @@ -1858,12 +1979,11 @@ export const tools = (sdk) => { } try { - const response = await fetchWithRetry({ + const response = await requestComposio({ + apiKey, url: `${baseUrl}/connected_accounts?${qs.toString()}`, method: "GET", - headers: buildHeaders(apiKey), timeoutMs, - log: sdk.log, }); if (response.status !== 200) { @@ -1949,12 +2069,11 @@ export const tools = (sdk) => { const connectedAccountId = params.connected_account_id.trim(); try { - const response = await fetchWithRetry({ + const response = await requestComposio({ + apiKey, url: `${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`, method: "GET", - headers: buildHeaders(apiKey), timeoutMs, - log: sdk.log, }); if (response.status !== 200 || !isRecord(response.data)) { @@ -2178,7 +2297,12 @@ export const tools = (sdk) => { if (params.cursor) qs.set("cursor", String(params.cursor)); try { - const response = await callComposio({ apiKey, path: "/files/list", query: qs }); + const response = await callComposio({ + apiKey, + path: "/files/list", + query: qs, + supportsUserApiKey: false, + }); if (response.status !== 200) { return failedResponse("Could not list files", response); } @@ -2259,6 +2383,7 @@ export const tools = (sdk) => { path: "/files/upload/request", method: "POST", body, + supportsUserApiKey: false, }); if (!isOkStatus(response.status, [200, 201]) || !isRecord(response.data)) { return failedResponse("Could not request file upload", response); @@ -2772,6 +2897,7 @@ export const tools = (sdk) => { const response = await callComposio({ apiKey, path: "/webhook_subscriptions/event_types", + supportsUserApiKey: false, }); if (response.status !== 200) { return failedResponse("Could not list webhook event types", response); @@ -2834,6 +2960,7 @@ export const tools = (sdk) => { apiKey, path: "/webhook_subscriptions", query: qs, + supportsUserApiKey: false, }); if (response.status !== 200) { return failedResponse("Could not list webhook subscriptions", response); @@ -2892,6 +3019,7 @@ export const tools = (sdk) => { const response = await callComposio({ apiKey, path: `/webhook_subscriptions/${encodeURIComponent(params.webhook_id)}`, + supportsUserApiKey: false, }); if (response.status !== 200 || !isRecord(response.data)) { return failedResponse(`Could not get webhook ${params.webhook_id}`, response); @@ -2968,6 +3096,7 @@ export const tools = (sdk) => { path: "/webhook_subscriptions", method: "POST", body, + supportsUserApiKey: false, }); if (!isOkStatus(response.status, [200, 201]) || !isRecord(response.data)) { return failedResponse("Could not create webhook subscription", response); @@ -3050,6 +3179,7 @@ export const tools = (sdk) => { path: `/webhook_subscriptions/${encodeURIComponent(params.webhook_id)}`, method: "PATCH", body, + supportsUserApiKey: false, }); if (!isOkStatus(response.status, [200, 204]) || (response.status !== 204 && !isRecord(response.data))) { return failedResponse(`Could not update webhook ${params.webhook_id}`, response); @@ -3108,6 +3238,7 @@ export const tools = (sdk) => { apiKey, path: `/webhook_subscriptions/${encodeURIComponent(params.webhook_id)}/rotate_secret`, method: "POST", + supportsUserApiKey: false, }); if (!isOkStatus(response.status, [200, 201]) || !isRecord(response.data)) { return failedResponse(`Could not rotate webhook ${params.webhook_id} secret`, response); @@ -3158,6 +3289,7 @@ export const tools = (sdk) => { apiKey, path: `/webhook_subscriptions/${encodeURIComponent(params.webhook_id)}`, method: "DELETE", + supportsUserApiKey: false, }); if (!isOkStatus(response.status, [200, 204])) { return failedResponse(`Could not delete webhook ${params.webhook_id}`, response); diff --git a/plugins/composio-direct/manifest.json b/plugins/composio-direct/manifest.json index 10f17ff..e141ba7 100644 --- a/plugins/composio-direct/manifest.json +++ b/plugins/composio-direct/manifest.json @@ -1,7 +1,7 @@ { "id": "composio-direct", "name": "Composio Direct", - "version": "1.9.2", + "version": "1.9.3", "description": "Direct access to 1000+ Composio automation tools plus v3.1 toolkits, files, triggers, webhooks, connection reuse, and meta-tools without MCP transport", "author": { "name": "xlabtg", @@ -15,7 +15,7 @@ "composio_api_key": { "required": true, "env": "COMPOSIO_DIRECT_COMPOSIO_API_KEY", - "description": "Composio API key (create at https://app.composio.dev/settings)" + "description": "Composio project or user API key (create at https://app.composio.dev/settings)" } }, "defaultConfig": { @@ -24,6 +24,7 @@ "max_parallel_executions": 10, "tool_version": "latest", "toolkit_versions": "latest", + "api_key_auth_scheme": "auto", "auth_config_ids": {} }, "tools": [ diff --git a/plugins/composio-direct/package.json b/plugins/composio-direct/package.json index e40e766..b8b501a 100644 --- a/plugins/composio-direct/package.json +++ b/plugins/composio-direct/package.json @@ -1,7 +1,7 @@ { "name": "teleton-plugin-composio-direct", "type": "module", - "version": "1.9.2", + "version": "1.9.3", "private": true, "description": "Teleton plugin for direct Composio API access" } diff --git a/plugins/composio-direct/test/integration/composio-api.test.js b/plugins/composio-direct/test/integration/composio-api.test.js index c1a9097..44be91c 100644 --- a/plugins/composio-direct/test/integration/composio-api.test.js +++ b/plugins/composio-direct/test/integration/composio-api.test.js @@ -207,11 +207,11 @@ describe("multi-execute batching", () => { }); it("stops after first failure with fail_fast=true", async () => { - // Return 401 (API-key error) which is NOT retried, so the first tool definitely fails + // Return a non-auth API error so this test only covers fail_fast behavior. let callCount = 0; const restore = mockFetchFactory(async () => { callCount++; - return { status: 401, data: { message: "Unauthorized" } }; + return { status: 400, data: { message: "Bad request" } }; }); try { diff --git a/plugins/composio-direct/test/unit/composio-direct.test.js b/plugins/composio-direct/test/unit/composio-direct.test.js index da09899..e73f57d 100644 --- a/plugins/composio-direct/test/unit/composio-direct.test.js +++ b/plugins/composio-direct/test/unit/composio-direct.test.js @@ -149,8 +149,9 @@ describe("manifest", () => { assert.ok(manifest.name, "manifest.name is set"); assert.ok(manifest.version, "manifest.version is set"); assert.ok(manifest.secrets?.composio_api_key, "secret composio_api_key declared"); - assert.equal(manifest.version, "1.9.2"); + assert.equal(manifest.version, "1.9.3"); assert.equal(manifest.defaultConfig?.base_url, "https://backend.composio.dev/api/v3.1"); + assert.equal(manifest.defaultConfig?.api_key_auth_scheme, "auto"); }); }); diff --git a/plugins/composio-direct/tests/index.test.js b/plugins/composio-direct/tests/index.test.js index 1ec17c7..4a57cdd 100644 --- a/plugins/composio-direct/tests/index.test.js +++ b/plugins/composio-direct/tests/index.test.js @@ -109,8 +109,9 @@ describe("composio-direct Teleton integration", () => { const sdk = makeSdk(); const toolList = toolsFactory(sdk); - assert.equal(manifest.version, "1.9.2"); + assert.equal(manifest.version, "1.9.3"); assert.equal(manifest.defaultConfig.base_url, "https://backend.composio.dev/api/v3.1"); + assert.equal(manifest.defaultConfig.api_key_auth_scheme, "auto"); assert.deepEqual( toolList.map((tool) => tool.name).sort(), expectedToolNames @@ -461,6 +462,121 @@ describe("composio-direct Teleton integration", () => { } }); + it("retries execute with x-user-api-key when x-api-key is forbidden", async () => { + const { calls, restore } = mockFetch((call, idx) => { + if (idx === 1) { + assert.equal(call.headers["x-api-key"], "test-api-key"); + assert.equal(call.headers["x-user-api-key"], undefined); + return { + status: 403, + data: { + error: { + message: "The API key doesn't have permissions to perform the request.", + slug: "HTTP_Forbidden", + status: 403, + }, + }, + }; + } + + assert.equal(call.headers["x-api-key"], undefined); + assert.equal(call.headers["x-user-api-key"], "test-api-key"); + return { + status: 200, + data: { + successful: true, + data: { ok: true }, + }, + }; + }); + + try { + const executeTool = toolsFactory(makeSdk()).find((tool) => tool.name === "composio_execute_tool"); + const result = await executeTool.execute( + { tool_slug: "github_list_repos", parameters: {} }, + makeContext() + ); + + assert.equal(result.success, true); + assert.equal(result.data.ok, true); + assert.equal(calls.length, 2); + } finally { + restore(); + } + }); + + it("uses x-user-api-key first when api_key_auth_scheme is user", async () => { + const { calls, restore } = mockFetch((call) => { + assert.equal(call.headers["x-api-key"], undefined); + assert.equal(call.headers["x-user-api-key"], "test-api-key"); + return { + status: 200, + data: { + successful: true, + data: { ok: true }, + }, + }; + }); + + try { + const executeTool = toolsFactory( + makeSdk({ pluginConfig: { api_key_auth_scheme: "user" } }) + ).find((tool) => tool.name === "composio_execute_tool"); + const result = await executeTool.execute( + { tool_slug: "github_list_repos", parameters: {} }, + makeContext() + ); + + assert.equal(result.success, true); + assert.equal(result.data.ok, true); + assert.equal(calls.length, 1); + } finally { + restore(); + } + }); + + it("keeps the actionable x-api-key 403 when x-user-api-key fallback is also rejected", async () => { + const { calls, restore } = mockFetch((call, idx) => { + if (idx === 1) { + assert.equal(call.headers["x-api-key"], "test-api-key"); + assert.equal(call.headers["x-user-api-key"], undefined); + return { + status: 403, + data: { + error: { + message: "The API key doesn't have permissions to perform the request.", + slug: "HTTP_Forbidden", + status: 403, + }, + }, + }; + } + + assert.equal(call.headers["x-api-key"], undefined); + assert.equal(call.headers["x-user-api-key"], "test-api-key"); + return { + status: 401, + data: { message: "Unauthorized" }, + }; + }); + + try { + const executeTool = toolsFactory(makeSdk()).find((tool) => tool.name === "composio_execute_tool"); + const result = await executeTool.execute( + { tool_slug: "github_list_repos", parameters: {} }, + makeContext() + ); + + assert.equal(result.success, false); + assert.equal(result.auth, undefined); + assert.match(result.error, /permission denied/i); + assert.match(result.error, /permissions/i); + assert.equal(calls.length, 2); + } finally { + restore(); + } + }); + it("passes connected_account_id in multi_execute HTTP body", async () => { const { calls, restore } = mockFetch(() => ({ status: 200,