From 8c3735dde870cde9bb8d35854ac28f6416bf2341 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 21 Aug 2026 20:46:27 +0200 Subject: [PATCH 1/4] chore(composio): add @composio/core and require Node 22 @composio/core depends directly on openai ^7.2.0, so installing it deduped the shared transitive openai from 6.47.0 to 7.5.0 across @copilotkit/runtime, @langchain/core, langchain and @ag-ui/langgraph. That is inside @copilotkit/runtime's declared peer range (^4.85.1 || >=5.0.0), so it is an allowed dedupe rather than a violated constraint, and pinning back is not possible without breaking Composio. openai@7 declares engines node >=22 and this repository declared none. Every deploy target already satisfies it -- CI runs 22.x and the Dockerfile pins node:22.16.0 by digest -- but a self-hoster on Node 20 would otherwise hit a confusing runtime failure instead of a clear install error. --- .gitignore | 3 ++ package.json | 5 ++ pnpm-lock.yaml | 121 ++++++++++++++++++++++++++++++++++++------------- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 2a1463b..06c1d1e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ state.db-wal # AWS CDK synthesis artifacts deployment/aws/cdk.out deployment/aws/cdk.context.json + +# Subagent-driven-development scratch (ledger, briefs, review packages) +.superpowers diff --git a/package.json b/package.json index 9a04d60..7672242 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "private": true, "description": "OpenTag — an open-source, self-hosted knowledge-work agent with connected tools and generative UI for Slack and Microsoft Teams.", "license": "MIT", + "engines": { + "node": ">=22" + }, "type": "module", "scripts": { "setup:dev": "uv sync --project agent --locked && pnpm exec playwright install chromium", @@ -14,12 +17,14 @@ "start": "tsx server.ts", "runtime": "tsx server.ts", "agent": "cd agent && uv run python main.py", + "composio:connect": "tsx scripts/composio-connect.ts", "check-types": "tsc --noEmit -p tsconfig.json", "test": "vitest run", "e2e": "tsx e2e/run.ts" }, "dependencies": { "@ag-ui/client": "^0.0.57", + "@composio/core": "^0.17.0", "@copilotkit/channels": "0.9.0", "@copilotkit/runtime": "1.68.1", "dotenv": "^16.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d88b1..79ff470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,15 @@ importers: '@ag-ui/client': specifier: ^0.0.57 version: 0.0.57 + '@composio/core': + specifier: ^0.17.0 + version: 0.17.0(ws@8.21.1)(zod@3.25.76) '@copilotkit/channels': specifier: 0.9.0 version: 0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/runtime': specifier: 1.68.1 - version: 1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + version: 1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -203,6 +206,19 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@composio/client@0.1.0-alpha.76': + resolution: {integrity: sha512-MXC5JGRVdiQ4EgLricy9o/mqBa1+1T7wHFZ6Q4ZJkrjzZqOMvxTgy21Zlb5J/1oGkB2bg9UDzpH8PkXCp/D4zA==} + + '@composio/core@0.17.0': + resolution: {integrity: sha512-HJzpKWgise3piltWorEHt07nrjUR3gPZnBmrFgnJXx5+6AiKn79YGBYLytdtuMscElLt/7pHOIxfcaYpiGkJ7A==} + peerDependencies: + zod: '>=3.25.76 <5' + + '@composio/json-schema-to-zod@0.3.0': + resolution: {integrity: sha512-Lgz5QlclPsd9mo8FGn/12h3cwTpN6A9aYvucO1umjnuvyetb6nC//nHEu9TDU+BN5Sa0n+qmUUv4Tzd4wI4xeA==} + peerDependencies: + zod: '>=3.25.76 <5' + '@copilotkit/channels-core@0.9.0': resolution: {integrity: sha512-bCWLb/jb9j8O+JvOvf/jkJ/Nb8B09U/tAdTCQ221mE2AEHS2dn5PMlQih9gqyF6kbJFO0Nmf+IDTritWswmfaA==} peerDependencies: @@ -1508,6 +1524,9 @@ packages: is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-fs-case-sensitive@2.0.0: + resolution: {integrity: sha512-JoCsyGITdYPM+pUbeMQ4IiEuQ4wjPdeWORlG7n644isewbcxiQIr+9gmF5k7UabTP/jLBRkbgydEamR7JZBKHA==} + is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -1828,8 +1847,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openai@6.47.0: - resolution: {integrity: sha512-xYr+R9woSzWxVxeiqkkNbHhv89tZDEI6eBMbrdPnv3poh+mijHvbhS35a+3o6xHa411/ns8j5ENY3So9DCXWYw==} + openai@7.5.0: + resolution: {integrity: sha512-ZbDBz8FSB8Mv8fFYIUvzTFMdV5vl93/octp1MdtK2lfYepSpfv/ewmeugpKz/cwGtFSx+YuUM4NwpZ2P55YiPA==} + engines: {node: '>=22.0.0'} peerDependencies: '@aws-sdk/credential-provider-node': '>=3.972.0 <4' '@smithy/hash-node': '>=4.3.0 <5' @@ -1956,6 +1976,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pusher-js@8.6.0: + resolution: {integrity: sha512-wShJPfCS/kYkCBVzVW67wa9cnQIgHTszEK2XHNrFkOgGruuGw081aERAxfRjfdFU+WcIt8x6dvbwkTW4iZuQ8Q==} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -2171,6 +2194,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-graphql@2.0.0-rc.1: resolution: {integrity: sha512-HCu4j3jR0tZvAAoO7DMBT3MRmah0DFRe5APymm9lXUghXA0sbhiMf6SLRafRYfk0R0KiUQYRduuGP3ap1RnF1Q==} engines: {node: '>= 18.12.0'} @@ -2429,14 +2455,14 @@ snapshots: '@ag-ui/core': 0.0.57 '@ag-ui/proto': 0.0.57 - '@ag-ui/langgraph@0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': + '@ag-ui/langgraph@0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': dependencies: '@ag-ui/a2ui-toolkit': 0.0.4 '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) partial-json: 0.1.7 rxjs: 7.8.2 transitivePeerDependencies: @@ -2597,6 +2623,30 @@ snapshots: '@cfworker/json-schema@4.1.1': {} + '@composio/client@0.1.0-alpha.76': {} + + '@composio/core@0.17.0(ws@8.21.1)(zod@3.25.76)': + dependencies: + '@composio/client': 0.1.0-alpha.76 + '@composio/json-schema-to-zod': 0.3.0(zod@3.25.76) + '@types/json-schema': 7.0.15 + is-fs-case-sensitive: 2.0.0 + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) + picocolors: 1.1.1 + pusher-js: 8.6.0 + semver: 7.8.5 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - ws + + '@composio/json-schema-to-zod@0.3.0(zod@3.25.76)': + dependencies: + zod: 3.25.76 + '@copilotkit/channels-core@0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: '@ag-ui/client': 0.0.57 @@ -2759,13 +2809,13 @@ snapshots: '@copilotkit/license-verifier@0.5.0': {} - '@copilotkit/runtime@1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/runtime@1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2) '@ag-ui/client': 0.0.57 '@ag-ui/core': 0.0.57 '@ag-ui/encoder': 0.0.57 - '@ag-ui/langgraph': 0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@ag-ui/langgraph': 0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@ag-ui/mcp-middleware': 0.0.1(@cfworker/json-schema@4.1.1)(rxjs@7.8.2)(zod@3.25.76) '@ai-sdk/anthropic': 3.0.97(zod@3.25.76) @@ -2779,7 +2829,7 @@ snapshots: '@copilotkit/shared': 1.68.1(@ag-ui/core@0.0.57) '@graphql-yoga/plugin-defer-stream': 3.21.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@hono/node-server': 1.19.14(hono@4.12.30) - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@remix-run/node-fetch-server': 0.13.3 '@scarf/scarf': 1.4.0 @@ -2805,9 +2855,9 @@ snapshots: ws: 8.21.1 zod: 3.25.76 optionalDependencies: - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - '@opentelemetry/api' @@ -3093,12 +3143,12 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': + '@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': dependencies: '@cfworker/json-schema': 4.1.1 '@standard-schema/spec': 1.1.0 js-tiktoken: 1.0.21 - langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) mustache: 4.2.0 p-queue: 6.6.2 zod: 3.25.76 @@ -3109,23 +3159,23 @@ snapshots: - openai - ws - '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': + '@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@langchain/protocol': 0.0.18 '@types/json-schema': 7.0.15 p-queue: 9.3.1 p-retry: 7.1.1 - '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76)': + '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76)': dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) '@langchain/protocol': 0.0.18 '@standard-schema/spec': 1.1.0 zod: 3.25.76 @@ -4178,6 +4228,8 @@ snapshots: is-electron@2.2.2: {} + is-fs-case-sensitive@2.0.0: {} + is-network-error@1.3.2: {} is-promise@4.0.0: {} @@ -4240,12 +4292,12 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): + langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): dependencies: - '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@langchain/langgraph': 1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) - langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@langchain/langgraph': 1.4.8(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(zod@3.25.76) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) + langsmith: 0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) zod: 3.25.76 transitivePeerDependencies: - '@opentelemetry/api' @@ -4258,12 +4310,12 @@ snapshots: - vue - ws - langsmith@0.8.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): + langsmith@0.8.3(@opentelemetry/api@1.9.1)(openai@7.5.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1): dependencies: p-queue: 6.6.2 optionalDependencies: '@opentelemetry/api': 1.9.1 - openai: 6.47.0(ws@8.21.1)(zod@3.25.76) + openai: 7.5.0(ws@8.21.1)(zod@3.25.76) ws: 8.21.1 libphonenumber-js@1.13.8: {} @@ -4424,11 +4476,10 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.47.0(ws@8.21.1)(zod@3.25.76): + openai@7.5.0(ws@8.21.1)(zod@3.25.76): optionalDependencies: ws: 8.21.1 zod: 3.25.76 - optional: true p-finally@1.0.0: {} @@ -4544,6 +4595,10 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pusher-js@8.6.0: + dependencies: + tweetnacl: 1.0.3 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -4797,6 +4852,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@1.0.3: {} + type-graphql@2.0.0-rc.1(class-validator@0.14.4)(graphql-scalars@1.25.0(graphql@16.14.2))(graphql@16.14.2): dependencies: '@graphql-yoga/subscription': 5.0.5 From f147e3e8eba9790c77f3e0236f1f874cd037c293 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 21 Aug 2026 20:46:40 +0200 Subject: [PATCH 2/4] feat(composio): connect any Composio toolkit, shared or per-user Adds an optional Composio integration, inert unless COMPOSIO_API_KEY is set. Toolkits listed in COMPOSIO_TOOLKITS run as one shared team identity; toolkits in COMPOSIO_USER_TOOLKITS run as the Slack user who asked, who connects their own account from a card in the thread. Lives entirely in the TypeScript runtime. Identity is only available there: @copilotkit/channels-core has no forwardedProps, so a structured user id cannot reach the Python agent, while ChannelToolContext carries a platform-verified actor. Three Channel tools wrap the SDK rather than exposing Composio's own router tools, because the hosted executor would send calls from the model straight to Composio and bypass the approval gate entirely. Toolkits are large -- gmail 63 tools, linear 47, googlecalendar 49 -- so search returns five candidates with their input schemas inline. COMPOSIO_APPROVALS chooses when a human must confirm: off, destructive (default), or writes. Managed Channels cannot block on a choice, so a gated call posts a card, registers the pending call under a token, and ends the turn; the Approve handler executes and rewrites the card. Security properties, each covered by tests: sessions always disable Composio's remote bash and Python sandbox, which are on by default; a connect link is minted for whoever clicks and delivered privately, never posted, because a link binds the completing user's account to the id it was minted for; an unmapped tool slug is treated as destructive so it cannot slip past the gate; a personal-scope approval can only be actioned by the person it was composed for. Includes scripts/composio-connect.ts, the only way to bind a shared toolkit to the identity shared calls actually run as -- the dashboard's connect button binds a different user id. --- app/channel-components.test.ts | 62 ++ app/channel.tsx | 40 +- .../__tests__/confirm-tool-run.test.tsx | 368 ++++++++++ app/human-in-the-loop/confirm-tool-run.tsx | 289 ++++++++ app/human-in-the-loop/connect-account.tsx | 59 ++ app/human-in-the-loop/index.ts | 19 + app/tools/composio/__tests__/classify.test.ts | 46 ++ app/tools/composio/__tests__/client.test.ts | 109 +++ app/tools/composio/__tests__/config.test.ts | 134 ++++ .../composio/__tests__/connect-link.test.ts | 127 ++++ .../composio/__tests__/connect-tool.test.tsx | 677 ++++++++++++++++++ app/tools/composio/__tests__/index.test.ts | 188 +++++ app/tools/composio/__tests__/pending.test.ts | 136 ++++ .../composio/__tests__/run-tool.test.tsx | 492 +++++++++++++ app/tools/composio/__tests__/scopes.test.ts | 138 ++++ .../composio/__tests__/search-tool.test.ts | 300 ++++++++ app/tools/composio/__tests__/sessions.test.ts | 123 ++++ app/tools/composio/classify.ts | 24 + app/tools/composio/client.ts | 29 + app/tools/composio/config.ts | 86 +++ app/tools/composio/connect-link.ts | 120 ++++ app/tools/composio/connect-tool.tsx | 273 +++++++ app/tools/composio/index.ts | 85 +++ app/tools/composio/pending.ts | 89 +++ app/tools/composio/run-tool.tsx | 244 +++++++ app/tools/composio/scopes.ts | 93 +++ app/tools/composio/search-tool.ts | 161 +++++ app/tools/composio/sessions.ts | 114 +++ scripts/composio-connect.ts | 141 ++++ 29 files changed, 4761 insertions(+), 5 deletions(-) create mode 100644 app/channel-components.test.ts create mode 100644 app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx create mode 100644 app/human-in-the-loop/confirm-tool-run.tsx create mode 100644 app/human-in-the-loop/connect-account.tsx create mode 100644 app/tools/composio/__tests__/classify.test.ts create mode 100644 app/tools/composio/__tests__/client.test.ts create mode 100644 app/tools/composio/__tests__/config.test.ts create mode 100644 app/tools/composio/__tests__/connect-link.test.ts create mode 100644 app/tools/composio/__tests__/connect-tool.test.tsx create mode 100644 app/tools/composio/__tests__/index.test.ts create mode 100644 app/tools/composio/__tests__/pending.test.ts create mode 100644 app/tools/composio/__tests__/run-tool.test.tsx create mode 100644 app/tools/composio/__tests__/scopes.test.ts create mode 100644 app/tools/composio/__tests__/search-tool.test.ts create mode 100644 app/tools/composio/__tests__/sessions.test.ts create mode 100644 app/tools/composio/classify.ts create mode 100644 app/tools/composio/client.ts create mode 100644 app/tools/composio/config.ts create mode 100644 app/tools/composio/connect-link.ts create mode 100644 app/tools/composio/connect-tool.tsx create mode 100644 app/tools/composio/index.ts create mode 100644 app/tools/composio/pending.ts create mode 100644 app/tools/composio/run-tool.tsx create mode 100644 app/tools/composio/scopes.ts create mode 100644 app/tools/composio/search-tool.ts create mode 100644 app/tools/composio/sessions.ts create mode 100644 scripts/composio-connect.ts diff --git a/app/channel-components.test.ts b/app/channel-components.test.ts new file mode 100644 index 0000000..8e9dda9 --- /dev/null +++ b/app/channel-components.test.ts @@ -0,0 +1,62 @@ +/** + * What the Channel is actually told to register. + * + * A click that arrives after the in-process cache is gone is served by + * re-rendering the named component from `createChannel({ components })`. A card + * missing from that list resolves to nothing, `dispatch` raises + * `ActionExpiredError`, and the Channel swallows it (`create-channel.js`) — so + * the person clicks and nothing happens, with no error anywhere to explain it. + * + * The assertion is on the argument `createChannel` receives, not on any + * constant this file could read: the wiring is the thing that has to be right, + * and a future edit that passes a different array must fail here. + */ +import { describe, expect, it, vi } from "vitest"; + +type ChannelsModule = typeof import("@copilotkit/channels"); +type CreateChannelOptions = Parameters[0]; + +const optionsSeen: CreateChannelOptions[] = []; + +vi.mock("@copilotkit/channels", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createChannel: (options: CreateChannelOptions) => { + optionsSeen.push(options); + return actual.createChannel(options); + }, + }; +}); + +const { FakeAgent } = await import("@copilotkit/channels"); +const { createOpenTagChannel } = await import("./channel.js"); + +/** The `components` array of the most recent `createChannel` call, by name. */ +function registeredComponentNames(): string[] { + const components = optionsSeen.at(-1)?.components; + if (!Array.isArray(components)) { + throw new Error("createChannel was not given a components array"); + } + return components.map((component) => + typeof component === "function" ? component.name : String(component), + ); +} + +describe("createOpenTagChannel component registration", () => { + it("registers every card whose buttons outlive the turn that posted them", () => { + createOpenTagChannel("opentag", new FakeAgent()); + + // `ConfirmToolRun`'s entire premise is that the agent's turn already ended, + // and a `ConnectAccount` click works perfectly cold — it mints a fresh link + // and needs nothing from the process that posted the card. Dropping either + // is a restart-only breakage that no other test would notice. + expect(registeredComponentNames()).toEqual( + expect.arrayContaining([ + "ConfirmWrite", + "ConfirmToolRun", + "ConnectAccount", + ]), + ); + }); +}); diff --git a/app/channel.tsx b/app/channel.tsx index 311a2c3..02eab2c 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -13,11 +13,16 @@ import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { createAppContext } from "./context/app-context.js"; import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js"; -import { ConfirmWrite } from "./human-in-the-loop/index.js"; +import { + ConfirmToolRun, + ConfirmWrite, + ConnectAccount, +} from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; import { RenderChart } from "./tools/render-chart.js"; +import { composioTools } from "./tools/composio/index.js"; import { createAppTools } from "./tools/index.js"; import { subscribeThreadTool, @@ -39,16 +44,29 @@ export function createOpenTagChannel( tools: createAppTools(agentDisplayName), context: [...createAppContext(agentDisplayName)], commands: appCommands, + // Not bookkeeping: once the in-process cache is gone, a click is served by + // re-rendering the named component from here. An unregistered card's + // buttons raise `ActionExpiredError`, which the Channel swallows — so the + // person clicks and nothing happens at all. `ConfirmToolRun` and + // `ConnectAccount` are the ones that make this load-bearing; both exist to + // be clicked minutes later, long after their turn ended. components: [ IssueCard, IssueList, PageList, IncidentCard, ConfirmWrite, + ConfirmToolRun, + ConnectAccount, RenderChart, ], }); + // Built once, here, rather than per turn: an unconfigured deployment gets an + // empty array and never constructs the SDK, and a misconfigured one prints + // its warnings at boot instead of once per message. + const composio = composioTools(process.env, name); + type MessageHandlerInput = Parameters< Parameters[0] >[0]; @@ -84,7 +102,10 @@ export function createOpenTagChannel( if (message.actor.kind === "bot" || message.actor.kind === "app") return; if (await thread.isSubscribed()) { - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); return; } @@ -101,18 +122,27 @@ export function createOpenTagChannel( if (isNewConversation) { await thread.subscribe(); - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); return; } - await runAgentSafely({ thread, message }, [subscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + subscribeThreadTool, + ...composio, + ]); }); channel.onMessage(async ({ thread, message }) => { if (message.actor.kind === "bot" || message.actor.kind === "app") return; if (await thread.isSubscribed()) { - await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + await runAgentSafely({ thread, message }, [ + unsubscribeThreadTool, + ...composio, + ]); } }); diff --git a/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx b/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx new file mode 100644 index 0000000..159607b --- /dev/null +++ b/app/human-in-the-loop/__tests__/confirm-tool-run.test.tsx @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from "vitest"; +import { + renderToIR, + type ChannelNode, + type InteractionContext, + type ClickHandler, +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, + type ConfirmDecision, +} from "../confirm-tool-run.js"; + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if ( + children && + typeof children === "object" && + "type" in (children as object) + ) { + return [children as ChannelNode]; + } + return []; +} + +/** Concatenate the text of all descendant `text` nodes (depth-first). */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** All button nodes in the tree. */ +function findButtons(nodes: ChannelNode[]): ChannelNode[] { + const out: ChannelNode[] = []; + for (const n of nodes) { + if (n.type === "button") out.push(n); + out.push(...findButtons(childNodes(n))); + } + return out; +} + +function buttonByText(ir: ChannelNode[], text: string): ChannelNode { + const btn = findButtons(ir).find((b) => collectText(b) === text); + if (!btn) throw new Error(`button "${text}" not found`); + return btn; +} + +/** A click on the posted card, recording whatever the handler writes back. */ +function clickOn( + update: ReturnType, + value: ConfirmDecision, +): InteractionContext { + return { + thread: { update, resume: vi.fn(), post: vi.fn() }, + message: { ref: { id: "m1" } }, + action: { id: "b1", value }, + actor: { id: "U1" }, + } as unknown as InteractionContext; +} + +describe("toolRunFields", () => { + it("humanizes keys and stringifies values", () => { + expect(toolRunFields({ recipient_email: "a@b.c" })).toEqual([ + { label: "Recipient email", value: "a@b.c" }, + ]); + }); + + it("drops empty values but keeps 0 and false", () => { + expect( + toolRunFields({ a: "", b: null, c: [], priority: 0, draft: false }), + ).toEqual([ + { label: "Priority", value: "0" }, + { label: "Draft", value: "No" }, + ]); + }); + + it("joins arrays and truncates long values", () => { + expect(toolRunFields({ cc: ["a@b.c", "d@e.f"] })[0]?.value).toBe( + "a@b.c, d@e.f", + ); + const long = toolRunFields({ body: "x".repeat(400) })[0]?.value ?? ""; + expect(long.startsWith("x".repeat(300))).toBe(true); + expect(long).not.toContain("x".repeat(301)); + }); + + it("shows array members as JSON rather than [object Object]", () => { + // A row reading "[object Object]" looks populated while withholding + // everything, and unlike an elision it never admits anything was withheld. + const value = + toolRunFields({ attachments: ["notes.txt", { name: "q3.pdf" }] })[0] + ?.value ?? ""; + expect(value).toBe('notes.txt, {"name":"q3.pdf"}'); + expect(value).not.toContain("[object Object]"); + }); + + it("says how much a truncated value withheld", () => { + // A bare "…" reads identically on a 305-char body and a 4000-char one. + expect(toolRunFields({ body: "x".repeat(400) })[0]?.value).toBe( + `${"x".repeat(300)}… (100 more characters)`, + ); + expect(toolRunFields({ body: "x".repeat(4000) })[0]?.value).toContain( + "(3700 more characters)", + ); + // Same wording as the row cap, down to the singular. + expect(toolRunFields({ body: "x".repeat(301) })[0]?.value).toContain( + "(1 more character)", + ); + // A value at the limit is not elided at all. + expect(toolRunFields({ body: "x".repeat(300) })[0]?.value).toBe( + "x".repeat(300), + ); + }); + + it("caps the row count and says how many were hidden", () => { + const many = Object.fromEntries( + Array.from({ length: 20 }, (_, i) => [`field_${i}`, `v${i}`]), + ); + const rows = toolRunFields(many); + expect(rows).toHaveLength(13); + expect(rows.at(-1)).toEqual({ label: "…", value: "8 more fields" }); + }); +}); + +describe("ConfirmToolRun", () => { + it("renders the action, the fields table and Approve/Cancel", () => { + const ir = renderToIR( + , + ); + const { blocks, accent } = renderSlackMessage(ir); + + expect(accent).toBe("#010507"); + + const header = blocks.find((b) => b.type === "header") as + | { text: { text: string } } + | undefined; + expect(header?.text.text).toContain("Send Gmail message"); + + // No `columns` prop, so the first row is data rather than a header row. + const table = blocks.find((b) => b.type === "table") as + | { rows: { text: string }[][] } + | undefined; + expect(table?.rows.map((row) => row.map((cell) => cell.text))).toEqual([ + ["Recipient email", "a@b.c"], + ]); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + expect(actions?.elements.map((e) => e.text.text)).toEqual([ + "Approve", + "Cancel", + ]); + expect(actions?.elements[0]?.style).toBe("primary"); + }); + + it("omits the table when there is nothing worth showing", () => { + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + expect(blocks.some((b) => b.type === "table")).toBe(false); + }); + + it("warns and reddens the confirm button for a destructive run", () => { + const ir = renderToIR( + , + ); + const { blocks, accent } = renderSlackMessage(ir); + + expect(accent).toBe("#EB5757"); + + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("cannot be undone"); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + // The red marks the irreversible choice, never the safe escape hatch. + expect(actions?.elements.map((e) => e.text.text)).toEqual([ + "Delete", + "Cancel", + ]); + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("names a destructive run by its own verb, never by 'Delete'", () => { + /** The two action-button labels for a destructive `action`. */ + const labelsFor = (action: string): string[] => { + const { blocks } = renderSlackMessage( + renderToIR( + , + ), + ); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string } }[] } + | undefined; + return actions?.elements.map((e) => e.text.text) ?? []; + }; + + // A humanised Composio action leads with the app, not with the verb. + expect(labelsFor("Gmail delete thread")).toEqual(["Delete", "Cancel"]); + + // Real destructive slugs whose verb is not "delete". Measured against the + // live Composio API: LINEAR_REMOVE_ISSUE_LABEL, GOOGLECALENDAR_CLEAR_ + // CALENDAR, GOOGLECALENDAR_CHANNELS_STOP. + expect(labelsFor("Linear remove issue label")).toEqual([ + "Remove", + "Cancel", + ]); + expect(labelsFor("Googlecalendar clear calendar")).toEqual([ + "Clear", + "Cancel", + ]); + expect(labelsFor("Googlecalendar channels stop")).toEqual([ + "Stop", + "Cancel", + ]); + for (const action of [ + "Linear remove issue label", + "Googlecalendar clear calendar", + "Googlecalendar channels stop", + ]) { + expect(labelsFor(action)).not.toContain("Delete"); + } + + // Unrecognised verb: a vague button beats a wrong one. + expect(labelsFor("Zzz frobnicate widget")).toEqual(["Confirm", "Cancel"]); + + // Would otherwise render Cancel/Cancel, one cancelling the event and one + // cancelling the request. + const collision = labelsFor("Googlecalendar cancel event"); + expect(collision).toEqual(["Confirm", "Cancel"]); + expect(new Set(collision).size).toBe(2); + }); + + it("carries only the token in the button values, never the arguments", async () => { + const ir = renderToIR( + , + ); + + const approve = buttonByText(ir, "Approve"); + const cancel = buttonByText(ir, "Cancel"); + expect(approve.props.value).toEqual({ token: "tok-1", approved: true }); + expect(cancel.props.value).toEqual({ token: "tok-1", approved: false }); + + // Slack action values have a size limit and must not ferry user data. + expect(JSON.stringify(approve.props.value)).not.toContain( + "secret-body-text", + ); + expect(JSON.stringify(cancel.props.value)).not.toContain( + "secret-body-text", + ); + + // Both buttons run the decision themselves — no handler is passed in, and + // nothing is resumed here. No pending call exists under this token, so the + // real handler rewrites the card as expired: two clicks, two updates. + const update = vi.fn(); + await (approve.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-1", approved: true }), + ); + await (cancel.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-1", approved: false }), + ); + expect(update).toHaveBeenCalledTimes(2); + }); + + /** + * The property the card's shape exists for. A click that lands after a + * restart is served by re-rendering this component from its **stored** props + * — which have been through the state store and carry no functions. A + * handler taken as a prop would be missing here, the re-rendered button would + * have no `onClick`, and the dispatcher's `ActionExpiredError` is swallowed + * by the Channel: the person clicks and sees nothing at all. + */ + it("still carries a working handler when re-rendered from stored props alone", async () => { + const posted = { + action: "Gmail delete thread", + fields: [{ label: "Thread id", value: "t-1" }], + destructive: true, + token: "tok-cold", + }; + // What a durable store hands back: data only. + const stored = JSON.parse(JSON.stringify(posted)) as typeof posted; + + const button = buttonByText(renderToIR(), "Delete"); + expect(typeof button.props.onClick).toBe("function"); + + const update = vi.fn(); + await (button.props.onClick as ClickHandler)( + clickOn(update, { token: "tok-cold", approved: true }), + ); + + // The pending call is gone with the process, so the best available answer + // is the one the user actually gets — rather than silence. + expect(update).toHaveBeenCalledTimes(1); + expect(JSON.stringify(update.mock.calls[0])).toContain("expired"); + }); +}); + +describe("ToolRunOutcome", () => { + it("renders a green success card", () => { + const { blocks, accent } = renderSlackMessage( + renderToIR( + , + ), + ); + + expect(accent).toBe("#2E7D32"); + const header = blocks.find((b) => b.type === "header") as + | { text: { text: string } } + | undefined; + expect(header?.text.text).toContain("Send Gmail message"); + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("Sent."); + }); + + it("renders a red failure card", () => { + const { blocks, accent } = renderSlackMessage( + renderToIR( + , + ), + ); + + expect(accent).toBe("#EB5757"); + const section = blocks.find((b) => b.type === "section") as + | { text: { text: string } } + | undefined; + expect(section?.text.text).toContain("Declined."); + }); +}); diff --git a/app/human-in-the-loop/confirm-tool-run.tsx b/app/human-in-the-loop/confirm-tool-run.tsx new file mode 100644 index 0000000..bae26a8 --- /dev/null +++ b/app/human-in-the-loop/confirm-tool-run.tsx @@ -0,0 +1,289 @@ +/** + * Approval card for a Composio tool run. + * + * `ConfirmWrite` resumes a paused LangGraph interrupt. Nothing is paused here — + * managed Channels cannot block on a choice (`supportsBlockingChoice: false`), + * so the decision travels in the button `value` and the click handler does the + * work. The card carries only a token; arguments stay in process. + */ +import { + Actions, + Button, + Cell, + Header, + Message, + Row, + Section, + Table, +} from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; + +/** One argument of the pending run, already labelled and stringified. */ +export interface ConfirmToolRunField { + label: string; + value: string; +} + +/** Rows past this many are collapsed into a single "n more fields" row. */ +const MAX_FIELDS = 12; + +/** Longest value a single row shows before it is elided. */ +const MAX_VALUE = 300; + +/** `recipient_email` / `addTeams` -> `Recipient email` / `Add teams`. */ +function humanize(key: string): string { + const spaced = key + .replace(/(?<=[a-z0-9])(?=[A-Z])/g, " ") + .replace(/[_-]+/g, " ") + .trim(); + const [first, ...rest] = spaced.split(/\s+/); + if (!first) return key; + return [ + first[0]!.toUpperCase() + first.slice(1), + ...rest.map((w) => w.toLowerCase()), + ].join(" "); +} + +/** + * Carries no information for an approver. Deliberately not JS falsiness: `0` is + * a real priority and `false` a real flag, so both must survive — dropping them + * would show an approver an incomplete picture of what they are approving. + */ +function isEmpty(value: unknown): boolean { + if (value === null || value === undefined || value === "") return true; + if (Array.isArray(value)) return value.length === 0; + return typeof value === "object" && Object.keys(value as object).length === 0; +} + +/** + * One member of an array value. Objects become JSON, matching what the + * top-level object branch already does — `String({})` renders `[object + * Object]`, a row that looks populated while withholding everything, and unlike + * the elisions below it does not admit that anything was withheld. + */ +function stringifyMember(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (value !== null && typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** The approver-readable form of one argument value. */ +function stringify(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.map(stringifyMember).join(", "); + if (value !== null && typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** `1 more field` / `8 more fields` — one wording for every elision. */ +function more(count: number, unit: string): string { + return `${count} more ${unit}${count === 1 ? "" : "s"}`; +} + +/** + * A value clipped to `MAX_VALUE`, naming what it withheld. A bare `…` reads the + * same on a 305-character body as on a 4000-character one; the row cap already + * says how much it dropped, so the value elision says so too. + */ +function elide(text: string): string { + if (text.length <= MAX_VALUE) return text; + return `${text.slice(0, MAX_VALUE)}… (${more(text.length - MAX_VALUE, "character")})`; +} + +/** + * A tool call's raw arguments as table rows an approver can read. Bounded on + * both axes — a mail body or a fifty-key payload would otherwise push the + * buttons off the bottom of the card. + */ +export function toolRunFields( + args: Record, +): ConfirmToolRunField[] { + const rows: ConfirmToolRunField[] = []; + for (const [key, value] of Object.entries(args)) { + if (isEmpty(value)) continue; + rows.push({ label: humanize(key), value: elide(stringify(value)) }); + } + + if (rows.length > MAX_FIELDS) { + return [ + ...rows.slice(0, MAX_FIELDS), + { label: "…", value: more(rows.length - MAX_FIELDS, "field") }, + ]; + } + return rows; +} + +/** What a click reports back: which pending call, and whether it may run. */ +export type ConfirmDecision = { token: string; approved: boolean }; + +/** The label of the button that declines the run. */ +const DECLINE_LABEL = "Cancel"; + +/** + * Verbs that name an irreversible action, so the confirm button can say which + * one it is. `confirm-write.tsx`'s `DESTRUCTIVE` set, widened by the verbs real + * Composio slugs use (`GOOGLECALENDAR_CLEAR_CALENDAR`, + * `GOOGLECALENDAR_CHANNELS_STOP`). + * + * Unlike the sibling's set, this one does not decide *whether* a run is + * destructive — the caller's `destructive` prop does. It only names the button + * once the caller has said so. + */ +const DESTRUCTIVE_VERBS = new Set([ + "delete", + "remove", + "archive", + "cancel", + "revoke", + "clear", + "stop", + "trash", + "purge", + "destroy", + "drop", +]); + +/** + * The action's own destructive verb, e.g. `Linear remove issue label` -> + * `Remove`. Scans every word rather than taking the first the way + * `confirm-write.tsx`'s `verbOf` does, because a humanised Composio action + * leads with the app (`Gmail delete thread`), not with the verb. + */ +function destructiveVerbOf(action: string): string { + for (const word of action.trim().split(/\s+/)) { + if (DESTRUCTIVE_VERBS.has(word.toLowerCase())) { + return word[0]!.toUpperCase() + word.slice(1).toLowerCase(); + } + } + return ""; +} + +/** + * How the confirm button reads. A destructive run is named by its own verb, so + * the button never claims a remove is a delete — `Linear remove issue label` + * confirms with `Remove`. + * + * Falls back to `Confirm` twice over: when no verb is recognised (better a + * vague button than a wrong one), and when the verb would collide with the + * decline button — `Googlecalendar cancel event` would otherwise render two + * buttons both reading "Cancel", one cancelling the event and one cancelling + * the request. + */ +function confirmLabel(action: string, destructive: boolean): string { + if (!destructive) return "Approve"; + const verb = destructiveVerbOf(action); + if (!verb) return "Confirm"; + return verb.toLowerCase() === DECLINE_LABEL.toLowerCase() ? "Confirm" : verb; +} + +interface ConfirmToolRunProps { + /** Short imperative title of the run, e.g. 'Delete Gmail thread'. */ + action: string; + /** + * The run's arguments as approver-readable rows, rendered as a headerless + * two-column table. No `columns` prop is passed, so neither renderer emits a + * header row — "Field | Value" would spend a row restating the layout. + */ + fields: ConfirmToolRunField[]; + /** + * Whether the run cannot be undone. Decided by the caller from the tool's + * slug rather than derived from `action` here, because the Composio tool name + * is the authority on that, not the prose title. + */ + destructive: boolean; + /** Looks up the pending call. The args themselves never enter the payload. */ + token: string; +} + +/** + * Run the decision this button carries. + * + * Defined here rather than taken as a prop, and that is load-bearing. A click + * that arrives after a restart is resolved by re-rendering this component from + * its **stored props** and re-plucking `onClick`; a function handed in as a + * prop cannot survive that round trip, so the re-rendered button would carry no + * handler, the dispatcher would raise `ActionExpiredError`, and the Channel + * swallows that — the person would click and see nothing at all. Everything + * this closure captures (`action`) is a serializable prop, so it is rebuilt + * intact every time. Same reason `ConfirmWrite` defines its handlers inline. + * + * The import is dynamic only to keep the module graph acyclic: `run-tool.tsx` + * imports this card. It is a cache hit — the runtime loaded that module at + * startup, before any card could be posted. + */ +async function decide( + interaction: InteractionContext, + action: string, +): Promise { + const { handleToolRunDecision } = await import( + "../tools/composio/run-tool.js" + ); + await handleToolRunDecision(interaction, action); +} + +export function ConfirmToolRun({ + action, + fields, + destructive, + token, +}: ConfirmToolRunProps) { + return ( + +
{`${destructive ? "⚠️ " : ""}${action}`}
+
+ {destructive ? "This cannot be undone." : "Approve to continue."} +
+ {fields.length > 0 ? ( + + {fields.map((field) => ( + + {field.label} + {field.value} + + ))} +
+ ) : null} + + {/* + The warning colour marks the irreversible choice, not the safe one, so + Cancel stays neutral on a destructive card. + */} + + + +
+ ); +} + +/** Terminal state after a decision, replacing the card in place. */ +export function ToolRunOutcome({ + action, + text, + ok, +}: { + action: string; + text: string; + ok: boolean; +}) { + return ( + +
{`${ok ? "✅" : "⚠️"} ${action}`}
+
{text}
+
+ ); +} diff --git a/app/human-in-the-loop/connect-account.tsx b/app/human-in-the-loop/connect-account.tsx new file mode 100644 index 0000000..6c03974 --- /dev/null +++ b/app/human-in-the-loop/connect-account.tsx @@ -0,0 +1,59 @@ +/** + * The connect prompt. Public in the thread, and deliberately carries no URL: a + * connect link binds whoever completes it to the user id it was minted for, so + * a link posted in a channel is an account-takeover hazard. The link is minted + * on click, for the clicker, and delivered privately. + */ +import { Actions, Button, Context, Header, Message, Section } from "@copilotkit/channels"; +import type { InteractionContext } from "@copilotkit/channels"; + +/** What the button carries. The toolkit only — never an id, never a link. */ +export type ConnectRequest = { toolkit: string }; + +/** + * Mint and deliver the link for whoever clicked. + * + * Defined here rather than taken as a prop, and that is the difference between + * a working button and a dead one. A click after a restart is served by + * re-rendering this card from its **stored props**; a function passed in as a + * prop does not survive that, so the button would come back with no handler and + * the Channel swallows the resulting `ActionExpiredError` — the person clicks + * "Connect" and nothing happens, every time, with nothing to explain it. This + * flow needs no in-process state at all (the link is minted fresh, for the + * clicker), so re-derived it works exactly as well as it did before the + * restart. + * + * The import is dynamic only to keep the module graph acyclic: `connect-tool.tsx` + * imports this card. It resolves from the module cache — the runtime loaded + * that module at startup, before any card could be posted. + */ +async function connect(interaction: InteractionContext, toolkit: string) { + const { handleConnectClick } = await import("../tools/composio/connect-tool.js"); + await handleConnectClick(toolkit, interaction); +} + +export function ConnectAccount({ toolkit }: { toolkit: string }) { + const label = toolkit.charAt(0).toUpperCase() + toolkit.slice(1); + return ( + +
{`🔗 Connect ${label}`}
+
+ {`I need access to your ${label} account to do that. The link is private to whoever clicks.`} +
+ + + + + {`Google will show "Composio" — that's the service OpenTag uses to connect apps. Anyone else in this thread can click to connect their own account.`} + +
+ ); +} diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index d20645f..a793709 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -5,5 +5,24 @@ * * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. + * + * `ConfirmToolRun` is its sibling for Composio tool runs, which have no paused + * graph behind them: the decision rides in the button `value` and a click + * handler does the work. + * + * `ConnectAccount` asks one person to connect one of their own accounts. It is + * public in the thread but carries no link — see the file for why a pre-minted + * connect URL must never be posted where someone else can click it. */ export { ConfirmWrite } from "./confirm-write.js"; +export { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, +} from "./confirm-tool-run.js"; +export type { + ConfirmDecision, + ConfirmToolRunField, +} from "./confirm-tool-run.js"; +export { ConnectAccount } from "./connect-account.js"; +export type { ConnectRequest } from "./connect-account.js"; diff --git a/app/tools/composio/__tests__/classify.test.ts b/app/tools/composio/__tests__/classify.test.ts new file mode 100644 index 0000000..e6a9979 --- /dev/null +++ b/app/tools/composio/__tests__/classify.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { effectOf, needsApproval } from "../classify.js"; + +describe("effectOf", () => { + it("reads readOnlyHint", () => { + expect(effectOf(["readOnlyHint", "inbox"])).toBe("read"); + }); + + it("reads destructiveHint", () => { + expect(effectOf(["destructiveHint"])).toBe("destructive"); + }); + + it("treats create and update as write", () => { + expect(effectOf(["createHint"])).toBe("write"); + expect(effectOf(["updateHint"])).toBe("write"); + }); + + it("prefers destructive when a tool carries both hints", () => { + expect(effectOf(["readOnlyHint", "destructiveHint"])).toBe("destructive"); + }); + + it("treats unknown, empty, and missing tags as write", () => { + expect(effectOf(["important"])).toBe("write"); + expect(effectOf([])).toBe("write"); + expect(effectOf(undefined)).toBe("write"); + }); +}); + +describe("needsApproval", () => { + it("never asks when off", () => { + expect(needsApproval("destructive", "off")).toBe(false); + expect(needsApproval("write", "off")).toBe(false); + }); + + it("asks only for destructive by default", () => { + expect(needsApproval("destructive", "destructive")).toBe(true); + expect(needsApproval("write", "destructive")).toBe(false); + expect(needsApproval("read", "destructive")).toBe(false); + }); + + it("asks for anything that is not a read when writes", () => { + expect(needsApproval("destructive", "writes")).toBe(true); + expect(needsApproval("write", "writes")).toBe(true); + expect(needsApproval("read", "writes")).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/client.test.ts b/app/tools/composio/__tests__/client.test.ts new file mode 100644 index 0000000..ca7ae64 --- /dev/null +++ b/app/tools/composio/__tests__/client.test.ts @@ -0,0 +1,109 @@ +/** + * Covers client.ts — the lazily constructed Composio SDK client. + * + * The SDK is mocked because the properties that matter here are invisible from + * the returned object: that construction happens lazily, exactly once, and with + * telemetry off. Only an assertion on the constructor argument can see the last + * one, and it is load-bearing rather than hygiene — Composio's default + * telemetry registers SIGINT/SIGTERM handlers that re-raise the signal, which + * would truncate the graceful shutdown in server.ts. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Composio } from "@composio/core"; +import type { ComposioConfig } from "../config.js"; + +/** + * A fresh object per construction, so a reset is observable by identity. Not an + * arrow — the client calls this with `new`, and a returned object wins over the + * constructed `this`. + */ +const ComposioConstructor = vi.fn(function fakeComposio() { + return { + sessions: { create: vi.fn() }, + tools: { getRawComposioTools: vi.fn() }, + }; +}); + +vi.mock("@composio/core", () => ({ Composio: ComposioConstructor })); + +const { composioClient, resetComposioClient } = await import("../client.js"); + +/** + * Sampled at import time, before any test body runs. Importing the module must + * construct nothing — the whole design rests on an unconfigured deployment + * never instantiating the SDK. + */ +const constructionsAfterImport = ComposioConstructor.mock.calls.length; + +const config: ComposioConfig = { + apiKey: "ak_test", + workspaceToolkits: ["linear"], + userToolkits: [], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, +}; + +beforeEach(() => { + resetComposioClient(); + ComposioConstructor.mockClear(); +}); + +describe("composioClient", () => { + it("exposes the surfaces the cache needs", () => { + const client = composioClient(config); + expect(typeof client.sessions.create).toBe("function"); + expect(typeof client.tools.getRawComposioTools).toBe("function"); + }); + + it("constructs once and reuses the instance", () => { + expect(composioClient(config)).toBe(composioClient(config)); + expect(ComposioConstructor).toHaveBeenCalledTimes(1); + }); + + it("constructs nothing until it is called", () => { + expect(constructionsAfterImport).toBe(0); + + composioClient(config); + composioClient(config); + expect(ComposioConstructor).toHaveBeenCalledTimes(1); + }); + + it("constructs a new instance after a reset", () => { + const before = composioClient(config); + resetComposioClient(); + const after = composioClient(config); + + expect(after).not.toBe(before); + expect(ComposioConstructor).toHaveBeenCalledTimes(2); + }); + + it("disables telemetry and the version check", () => { + composioClient(config); + + // Telemetry's SIGINT/SIGTERM handlers re-raise the signal after removing + // themselves, which kills the process mid-drain in server.ts. The version + // check is an unasked-for network call at construction. + expect(ComposioConstructor).toHaveBeenCalledWith({ + apiKey: "ak_test", + allowTracking: false, + disableVersionCheck: true, + }); + }); +}); + +/** + * Compile-time guard on the `as unknown as ComposioSdk` cast in client.ts. That + * cast disables drift detection, and later tasks call through it, so assert the + * real instance still carries the two members `ComposioSdk` requires: an SDK + * rename then fails `pnpm check-types` instead of failing at runtime. + */ +interface SdkShapeGuard { + sessions: { create: (userId: string, options: Record) => unknown }; + tools: { getRawComposioTools: (query: { toolkits: string[]; limit: number }) => unknown }; +} + +/** Errors on the type argument, rather than quietly resolving to `never`. */ +type Satisfies = TActual; + +type _AssertSdkShape = Satisfies; diff --git a/app/tools/composio/__tests__/config.test.ts b/app/tools/composio/__tests__/config.test.ts new file mode 100644 index 0000000..488d5cd --- /dev/null +++ b/app/tools/composio/__tests__/config.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { readComposioConfig } from "../config.js"; + +describe("readComposioConfig", () => { + it("returns null without an API key", () => { + expect(readComposioConfig({ COMPOSIO_TOOLKITS: "linear" }, "open-tag")).toBeNull(); + }); + + it("returns null when a key is set but no toolkits are", () => { + expect(readComposioConfig({ COMPOSIO_API_KEY: "ak_x" }, "open-tag")).toBeNull(); + }); + + it("returns null for a whitespace-only API key", () => { + expect( + readComposioConfig({ COMPOSIO_API_KEY: " ", COMPOSIO_TOOLKITS: "linear" }, "open-tag"), + ).toBeNull(); + }); + + it("parses toolkits, trimming, lowercasing, dropping empties", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: " LINEAR , ,jira " }, + "open-tag", + ); + expect(config?.workspaceToolkits).toEqual(["linear", "jira"]); + }); + + it("defaults approvals to destructive and userId to the channel name", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }, + "open-tag", + ); + expect(config?.approvals).toBe("destructive"); + expect(config?.workspaceUserId).toBe("open-tag"); + }); + + it("overrides the userId when one is configured", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_TOOLKITS: "linear", + COMPOSIO_WORKSPACE_USER_ID: " acme-workspace ", + }, + "open-tag", + ); + expect(config?.workspaceUserId).toBe("acme-workspace"); + }); + + it("rejects an unknown approval mode", () => { + expect(() => + readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: "sometimes" }, + "open-tag", + ), + ).toThrow('Invalid COMPOSIO_APPROVALS: "sometimes"'); + }); + + it("treats an empty approvals value as unset", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: " " }, + "open-tag", + ); + expect(config?.approvals).toBe("destructive"); + }); + + it("accepts off and writes, case-insensitively", () => { + const off = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: "OFF" }, + "open-tag", + ); + expect(off?.approvals).toBe("off"); + + const writes = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear", COMPOSIO_APPROVALS: " Writes " }, + "open-tag", + ); + expect(writes?.approvals).toBe("writes"); + }); + + it("parses optional auth config pins", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "gmail:ac_123, jira:ac_456", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ gmail: "ac_123", jira: "ac_456" }); + }); + + it("preserves auth config id case", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "GMAIL:ac_ExAmPle1-aB, linear:ac_ExAmPle2Cd", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ + gmail: "ac_ExAmPle1-aB", + linear: "ac_ExAmPle2Cd", + }); + }); + + it("keeps a colon inside an auth config id and skips entries without one", () => { + const config = readComposioConfig( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", + COMPOSIO_AUTH_CONFIGS: "gmail:ac_a:b, bogus", + }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({ gmail: "ac_a:b" }); + }); + + it("defaults authConfigs to empty when unset", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }, + "open-tag", + ); + expect(config?.authConfigs).toEqual({}); + }); + + it("accepts either toolkit list alone", () => { + const config = readComposioConfig( + { COMPOSIO_API_KEY: "ak_x", COMPOSIO_USER_TOOLKITS: "gmail" }, + "open-tag", + ); + expect(config?.workspaceToolkits).toEqual([]); + expect(config?.userToolkits).toEqual(["gmail"]); + }); +}); diff --git a/app/tools/composio/__tests__/connect-link.test.ts b/app/tools/composio/__tests__/connect-link.test.ts new file mode 100644 index 0000000..2c405d3 --- /dev/null +++ b/app/tools/composio/__tests__/connect-link.test.ts @@ -0,0 +1,127 @@ +/** + * The two decisions `scripts/composio-connect.ts` makes before it calls + * anything. Nothing here touches the network — the script's own API calls are + * deliberately not exercised, since a success path would mint a real Connect + * Link against a real project. + */ +import { describe, expect, it } from "vitest"; +import { + resolveSharedToolkit, + selectAuthConfig, + type AuthConfigSummary, +} from "../connect-link.js"; + +const config = { workspaceToolkits: ["linear", "jira"], userToolkits: ["gmail"] }; + +function authConfig(id: string, slug: string, managed = false): AuthConfigSummary { + return { id, toolkit: { slug }, isComposioManaged: managed }; +} + +describe("resolveSharedToolkit", () => { + it("accepts a slug in COMPOSIO_TOOLKITS", () => { + expect(resolveSharedToolkit(config, "linear")).toEqual({ ok: true, value: "linear" }); + }); + + it("normalizes a shouted or padded slug", () => { + expect(resolveSharedToolkit(config, " LINEAR ")).toEqual({ ok: true, value: "linear" }); + }); + + it("refuses a personal toolkit and points at the in-thread flow", () => { + const result = resolveSharedToolkit(config, "gmail"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("personal toolkit"); + expect(result.message).toContain("Connect card"); + }); + + it("refuses an unconfigured toolkit and lists the shared ones", () => { + const result = resolveSharedToolkit(config, "salesforce"); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("COMPOSIO_TOOLKITS"); + expect(result.message).toContain("linear, jira"); + }); + + // Table-driven rather than a loop with an early return: `if (result.ok) + // return` inside a loop exits the whole test as a pass, so a regression that + // accepted only the *second* case would never fail it. + it.each([["undefined", undefined], ["empty", ""], ["whitespace", " "]])( + "prints usage when the argument is %s", + (_label, missing) => { + const result = resolveSharedToolkit(config, missing); + + expect(result).toEqual({ ok: false, message: expect.stringContaining("Usage:") }); + }, + ); + + it("says so plainly when no shared toolkit is configured at all", () => { + const result = resolveSharedToolkit({ workspaceToolkits: [], userToolkits: [] }, undefined); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("(none configured)"); + }); +}); + +describe("selectAuthConfig", () => { + it("uses the pin without consulting the listing", () => { + // Pinning is the override for a bad guess, so an empty listing is not an + // error when a pin exists. + expect(selectAuthConfig("linear", "ac_ExAmPle1-aB", [])).toEqual({ + ok: true, + value: "ac_ExAmPle1-aB", + }); + }); + + it("ignores a whitespace-only pin", () => { + const result = selectAuthConfig("linear", " ", [authConfig("ac_one", "linear")]); + + expect(result).toEqual({ ok: true, value: "ac_one" }); + }); + + it("takes the only auth config for the toolkit", () => { + const result = selectAuthConfig("linear", undefined, [ + authConfig("ac_gmail", "gmail"), + authConfig("ac_linear", "linear"), + ]); + + expect(result).toEqual({ ok: true, value: "ac_linear" }); + }); + + it("matches a slug the API returned in another case", () => { + const result = selectAuthConfig("linear", undefined, [authConfig("ac_linear", "LINEAR")]); + + expect(result).toEqual({ ok: true, value: "ac_linear" }); + }); + + it("tells the operator to add the toolkit when none exists", () => { + const result = selectAuthConfig("linear", undefined, [authConfig("ac_gmail", "gmail")]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("No auth config exists"); + expect(result.message).toContain("app.composio.dev"); + }); + + it("refuses to guess between several and asks for a pin", () => { + const result = selectAuthConfig("linear", undefined, [ + authConfig("ac_one", "linear", true), + authConfig("ac_two", "linear"), + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.message).toContain("ac_one"); + expect(result.message).toContain("Composio-managed"); + expect(result.message).toContain("ac_two"); + expect(result.message).toContain("COMPOSIO_AUTH_CONFIGS=linear:"); + }); + + it("does not crash on an item with no toolkit", () => { + const result = selectAuthConfig("linear", undefined, [{ id: "ac_orphan" }]); + + expect(result.ok).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/connect-tool.test.tsx b/app/tools/composio/__tests__/connect-tool.test.tsx new file mode 100644 index 0000000..be5e51a --- /dev/null +++ b/app/tools/composio/__tests__/connect-tool.test.tsx @@ -0,0 +1,677 @@ +/** + * `connect_my_app` is where one person's provider account gets bound to one + * Composio user id, so the tests are mostly about who that id belongs to. + * + * A connect link is a bearer capability: whoever completes the flow attaches + * THEIR mailbox to the id the link was minted for. A link minted for Alice and + * posted into a channel therefore hands Bob a way to become Alice. Half of what + * follows exists to hold that line — no URL in the public card, the link minted + * from the clicker, delivered ephemerally, never logged. + * + * The click handler is driven both ways: directly, and through the `onClick` + * the posted card actually carries, so a card wired to the wrong handler fails + * here rather than in production. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + renderToIR, + type ChannelNode, + type ClickHandler, + type InteractionContext, +} from "@copilotkit/channels"; +import { ConnectAccount } from "../../../human-in-the-loop/index.js"; +import { resetComposioClient } from "../client.js"; +import { createConnectTool, handleConnectClick } from "../connect-tool.js"; +import type { ComposioConfig } from "../config.js"; +import { + clearSessionCache, + getSession, + type Authorization, + type ComposioSdk, +} from "../sessions.js"; + +/** + * The click takes no SDK: it re-derives the memoized client from the + * environment, which is what lets a card posted before a restart still work. + * So the fake is installed where that client is constructed — `makeSdk` + * registers itself here, and every test drives the production path. + */ +let currentSdk: object | undefined; + +vi.mock("@composio/core", () => ({ + Composio: vi.fn(function fakeComposio() { + if (!currentSdk) throw new Error("no fake SDK was installed for this test"); + return currentSdk; + }), +})); + +const config: ComposioConfig = { + apiKey: "ak_x", + workspaceToolkits: [], + userToolkits: ["gmail"], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, +}; + +/** The bearer capability under test. Nothing public may ever contain it. */ +const REDIRECT = "https://connect.composio.dev/link/lk_x"; + +type Authorize = () => Promise; + +/** A promise plus the handles to settle it from the test body. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Let every already-queued microtask and timer callback run. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** A fake SDK that records every session it was asked to create. */ +function makeSdk(authorize: Authorize = async () => ({ redirectUrl: REDIRECT })) { + const created: Array<{ userId: string; options: Record }> = []; + const sdk = { + created, + sessions: { + create: vi.fn(async (userId: string, options: Record) => { + created.push({ userId, options }); + return { + sessionId: "trs_1", + authorize: vi.fn(authorize), + search: vi.fn(), + execute: vi.fn(), + toolkits: vi.fn(), + }; + }), + }, + tools: { getRawComposioTools: vi.fn(async () => []) }, + }; + currentSdk = sdk; + return sdk; +} + +/** `null` means the platform gave us no verified identity for this event. */ +type ActorId = string | null; + +function actorOf(actorId: ActorId) { + return actorId === null ? undefined : { id: actorId }; +} + +/** The tool handler's context: who asked, and the thread they asked in. */ +function makeCtx(actorId: ActorId = "U1") { + const post = vi.fn(); + const postEphemeral = vi.fn(); + return { + ctx: { actor: actorOf(actorId), thread: { post, postEphemeral } } as never, + post, + postEphemeral, + }; +} + +/** What a surface reports back from `postEphemeral`. `null` is a real answer. */ +type EphemeralOutcome = { ok: boolean; usedFallback?: boolean } | null; + +/** Slack: a native only-you message, delivered. */ +const NATIVE: EphemeralOutcome = { ok: true, usedFallback: false }; + +/** A button click: a different person, in the same thread. */ +function makeInteraction( + actorId: ActorId = "U2", + ephemeral: (() => Promise) | (() => never) = async () => NATIVE, +) { + const post = vi.fn(); + const postEphemeral = vi.fn( + async (_user: unknown, _ui: unknown, _opts: unknown): Promise => + ephemeral(), + ); + return { + interaction: { + actor: actorOf(actorId), + action: { id: "b1", value: { toolkit: "gmail" } }, + platform: "teams", + thread: { post, postEphemeral }, + } as unknown as InteractionContext<{ toolkit: string }>, + post, + postEphemeral, + }; +} + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if (children && typeof children === "object" && "type" in (children as object)) { + return [children as ChannelNode]; + } + return []; +} + +function findAll(nodes: ChannelNode[], type: string): ChannelNode[] { + const out: ChannelNode[] = []; + for (const node of nodes) { + if (node.type === type) out.push(node); + out.push(...findAll(childNodes(node), type)); + } + return out; +} + +/** Every descendant `text` node's text, concatenated depth-first. */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** + * Everything the card would carry to the platform — text, button values, props. + * Serialized rather than walked, because the assertion is about the whole + * payload: a URL smuggled into a button `value` is as public as one in a + * `Section`. + */ +function payloadOf(renderable: unknown): string { + return JSON.stringify(renderToIR(renderable as never), (_key, value) => + typeof value === "function" ? "[fn]" : value, + ); +} + +/** The click handler the posted card actually wires to its button. */ +function clickHandlerOf(renderable: unknown): ClickHandler<{ toolkit: string }> { + const button = findAll(renderToIR(renderable as never), "button")[0]; + const onClick = button?.props?.onClick; + if (typeof onClick !== "function") throw new Error("posted card carries no button handler"); + return onClick as ClickHandler<{ toolkit: string }>; +} + +/** Post the card, then return the handler its button carries. */ +async function postAndGetHandler(triggerer = "U1") { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(triggerer); + await tool.handler({ toolkit: "gmail" }, ctx); + return { onClick: clickHandlerOf(post.mock.calls[0]?.[0]), post }; +} + +/** Every string any console channel saw, so a leak anywhere is one assertion. */ +function captureConsole() { + const lines: string[] = []; + const record = (...args: unknown[]) => void lines.push(args.map(String).join(" ")); + for (const channel of ["log", "warn", "error", "info", "debug"] as const) { + vi.spyOn(console, channel).mockImplementation(record); + } + return lines; +} + +/** Env this deployment is configured with, restored after every test. */ +const ENV_UNDER_TEST = { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_USER_TOOLKITS: "gmail", +}; + +const savedEnv: Record = {}; + +beforeEach(() => { + clearSessionCache(); + // The click reads the environment, so the environment is part of the setup. + for (const [key, value] of Object.entries(ENV_UNDER_TEST)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } + // Each test installs its own fake through `makeSdk`; the memo must not carry + // the previous test's across. + resetComposioClient(); + currentSdk = undefined; +}); + +afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + vi.restoreAllMocks(); +}); + +describe("connect_my_app", () => { + it("posts a card and does not mint a link", async () => { + const sdk = makeSdk(); + const { ctx, post } = makeCtx(); + const tool = createConnectTool(config); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(sdk.sessions.create).not.toHaveBeenCalled(); + expect(String(result)).toContain("Connect"); + }); + + it("refuses a toolkit that is not configured", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "salesforce" }, ctx); + + expect(String(result)).toContain("not configured"); + expect(post).not.toHaveBeenCalled(); + }); + + it("refuses a shared team toolkit and says who connects it", async () => { + const sdk = makeSdk(); + const tool = createConnectTool({ ...config, workspaceToolkits: ["linear"] }); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "linear" }, ctx); + + expect(String(result)).toContain("shared team app"); + // Names the one path that works. The dashboard's connect button binds to + // the dashboard's own user id, so pointing there was worse than silence. + expect(String(result)).toContain("pnpm composio:connect linear"); + expect(post).not.toHaveBeenCalled(); + expect(sdk.sessions.create).not.toHaveBeenCalled(); + }); + + it("still connects a toolkit that is both shared and personal", async () => { + const tool = createConnectTool({ ...config, workspaceToolkits: ["gmail"] }); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).not.toContain("shared team app"); + }); + + it("refuses without a verified actor", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(null); + + const result = await tool.handler({ toolkit: "gmail" }, ctx); + + expect(String(result)).toContain("who you are"); + expect(post).not.toHaveBeenCalled(); + }); + + it("accepts a slug the model shouted or padded", async () => { + const tool = createConnectTool(config); + const { ctx, post } = makeCtx(); + + const result = await tool.handler({ toolkit: " GMAIL " }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).not.toContain("not configured"); + }); +}); + +describe("the public card", () => { + it("carries a button and no URL", async () => { + const { ctx, post } = makeCtx(); + const tool = createConnectTool(config); + + await tool.handler({ toolkit: "gmail" }, ctx); + + const card = post.mock.calls[0]?.[0]; + const payload = payloadOf(card); + expect(findAll(renderToIR(card as never), "button")).toHaveLength(1); + // Not just the known link: ANY url in the public payload is the bug. + expect(payload).not.toContain("http"); + expect(payload).not.toContain(REDIRECT); + expect(renderToIR(card as never).map(collectText).join("")).toContain("Connect Gmail"); + }); + + it("is posted to the thread, not sent privately to the triggerer", async () => { + const { ctx, post, postEphemeral } = makeCtx(); + const tool = createConnectTool(config); + + await tool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(postEphemeral).not.toHaveBeenCalled(); + }); +}); + +describe("the connect click", () => { + it("mints the link for the clicker, not for whoever triggered the card", async () => { + const sdk = makeSdk(); + const { onClick } = await postAndGetHandler("U1"); + const { interaction } = makeInteraction("U2"); + + await onClick(interaction); + + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + expect(sdk.created[0]?.userId).toBe("U2"); + expect(sdk.created[0]?.userId).not.toBe("U1"); + }); + + it("creates the connect session with the workbench disabled", async () => { + const sdk = makeSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(sdk.created[0]?.options).toMatchObject({ + toolkits: ["gmail"], + workbench: { enable: false }, + }); + }); + + it("delivers the link privately to the clicker and never to the channel", async () => { + const sdk = makeSdk(); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + const [target, ui, opts] = postEphemeral.mock.calls[0]!; + expect(target).toEqual({ id: "U2" }); + expect(String(ui)).toContain(REDIRECT); + expect(opts).toEqual({ fallbackToDM: true }); + }); + + it("never logs the redirect url", async () => { + const lines = captureConsole(); + const sdk = makeSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(lines.join("\n")).not.toContain(REDIRECT); + expect(lines.join("\n")).not.toContain("http"); + }); + + it("drops the clicker's cached sessions so the new app is usable next message", async () => { + const sdk = makeSdk(); + const typed = sdk as unknown as ComposioSdk; + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + + const { interaction } = makeInteraction("U2"); + await handleConnectClick("gmail", interaction); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + + // A third create means the cached entry is gone, not merely stale. + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(3); + }); + + it("leaves another user's cached session alone", async () => { + const sdk = makeSdk(); + const typed = sdk as unknown as ComposioSdk; + await getSession(typed, "U9", ["gmail"]); + + const { interaction } = makeInteraction("U2"); + await handleConnectClick("gmail", interaction); + + await getSession(typed, "U9", ["gmail"]); + // Priming plus the click. A third would mean U9 was evicted too. + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("ignores a click it cannot attribute to anyone", async () => { + const sdk = makeSdk(); + const { interaction, post, postEphemeral } = makeInteraction(null); + + await handleConnectClick("gmail", interaction); + + expect(sdk.sessions.create).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("reports a failed authorize privately, with no link", async () => { + const sdk = makeSdk(async () => { + throw new Error("composio is down"); + }); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(String(postEphemeral.mock.calls[0]?.[1])).not.toContain("http"); + }); + + it("reports an authorize that came back without a link", async () => { + const sdk = makeSdk(async () => ({ redirectUrl: "" })); + const { interaction, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + const text = String(postEphemeral.mock.calls[0]?.[1]); + expect(text).not.toContain("Authorize"); + expect(text).toContain("could not"); + }); +}); + +/** + * A surface without a private channel is the case that decides whether this + * flow degrades or leaks. Teams implements no `postEphemeral`, so channels-core + * answers `{ ok: false }` — and OpenTag advertises Teams support, so an ignored + * result is a dead platform, not an edge case. + */ +describe("a surface with no private message", () => { + const noPrivateChannel: Array<[string, () => Promise]> = [ + ["reports {ok:false} (Teams: no ephemeral path at all)", async () => ({ ok: false })], + ["resolves null (no ephemeral, no DM fallback)", async () => null], + [ + "throws (the transport failed)", + async () => { + throw new Error("request to https://teams.example/api failed"); + }, + ], + ]; + + for (const [name, ephemeral] of noPrivateChannel) { + it(`says so instead of posting the link when the surface ${name}`, async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2", ephemeral); + + await expect( + handleConnectClick("gmail", interaction), + ).resolves.toBeUndefined(); + + expect(post).toHaveBeenCalledTimes(1); + const text = String(post.mock.calls[0]?.[0]); + // The whole point: no private channel means the link goes nowhere. + expect(text).not.toContain(REDIRECT); + expect(text).not.toContain("http"); + expect(text).toContain("teams"); + }); + } + + it("keeps the provider's error out of the channel when it cannot be sent privately", async () => { + const sdk = makeSdk(async () => { + throw new Error("composio said U2's account is barred"); + }); + const { interaction, post } = makeInteraction("U2", async () => ({ ok: false })); + + await handleConnectClick("gmail", interaction); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(post.mock.calls[0]?.[0])).not.toContain("barred"); + }); + + it("says where the link went when it fell back to a DM", async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2", async () => ({ + ok: true, + usedFallback: true, + })); + + await handleConnectClick("gmail", interaction); + + expect(post).toHaveBeenCalledTimes(1); + const text = String(post.mock.calls[0]?.[0]); + expect(text).toContain("direct message"); + expect(text).not.toContain("http"); + }); + + it("posts nothing publicly when the private message landed natively", async () => { + const sdk = makeSdk(); + const { interaction, post } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(post).not.toHaveBeenCalled(); + }); +}); + +/** + * The mint-time invalidation is too early on its own: the tool tells the user + * to ask again, and asking again refills the cache with a session that still + * predates the connection. So the completion is chained too — without ever + * being awaited, because the turn has to end before Slack cuts the update. + */ +describe("the connection completing later", () => { + /** An SDK whose authorization exposes a wait the test settles by hand. */ + function makeWaitingSdk() { + const pending = deferred(); + const waitForConnection = vi.fn(async (_timeoutMs?: number) => pending.promise); + const sdk = makeSdk(async () => ({ redirectUrl: REDIRECT, waitForConnection })); + return { sdk, pending, waitForConnection }; + } + + it("returns without waiting for the browser flow", async () => { + makeWaitingSdk(); + const { interaction } = makeInteraction("U2"); + + const outcome = await Promise.race([ + handleConnectClick("gmail", interaction).then( + () => "returned", + ), + new Promise((resolve) => setTimeout(() => resolve("still waiting"), 50)), + ]); + + expect(outcome).toBe("returned"); + }); + + it("waits far longer than Composio's own default", async () => { + const { waitForConnection } = makeWaitingSdk(); + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + // Ten minutes. Composio's ~60s default would reject before a real consent + // screen is read, making the whole chain useless. + expect(waitForConnection).toHaveBeenCalledWith(10 * 60 * 1000); + }); + + it("drops the clicker's cache again once the connection completes", async () => { + const { sdk, pending } = makeWaitingSdk(); + const typed = sdk as unknown as ComposioSdk; + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + // The user does what the tool told them to: asks again, mid-flow. This + // refills the cache with a session that predates the connection. + await getSession(typed, "U2", ["gmail"]); + const beforeConnect = sdk.sessions.create.mock.calls.length; + + pending.resolve({ id: "ca_1" }); + await flush(); + + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create.mock.calls.length).toBe(beforeConnect + 1); + }); + + it("survives an abandoned authorization without an unhandled rejection", async () => { + const unhandled: unknown[] = []; + const record = (reason: unknown) => void unhandled.push(reason); + process.on("unhandledRejection", record); + try { + const { sdk, pending } = makeWaitingSdk(); + const typed = sdk as unknown as ComposioSdk; + const { interaction } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + await getSession(typed, "U2", ["gmail"]); + const beforeTimeout = sdk.sessions.create.mock.calls.length; + + // What a closed browser tab looks like. Unhandled, this terminates the + // process on Node 22 — the bot would go down with the user's tab. + pending.reject(new Error("connection timed out")); + await flush(); + + expect(unhandled).toEqual([]); + // Nothing connected, so nothing is invalidated. + await getSession(typed, "U2", ["gmail"]); + expect(sdk.sessions.create.mock.calls.length).toBe(beforeTimeout); + } finally { + process.off("unhandledRejection", record); + } + }); + + it("does not chain a wait onto a link nobody could be sent", async () => { + const { waitForConnection } = makeWaitingSdk(); + const { interaction } = makeInteraction("U2", async () => ({ ok: false })); + + await handleConnectClick("gmail", interaction); + + expect(waitForConnection).not.toHaveBeenCalled(); + }); + + it("still works against a session that offers no wait at all", async () => { + makeSdk(); + const { interaction, postEphemeral } = makeInteraction("U2"); + + await expect( + handleConnectClick("gmail", interaction), + ).resolves.toBeUndefined(); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); +}); + +/** + * The regression that makes the card's shape load-bearing. + * + * A click arriving after a restart is served by re-rendering `ConnectAccount` + * from its **stored** props — which have been through the state store and carry + * no functions. A handler passed in as a prop would be gone, the re-rendered + * button would have no `onClick`, and the Channel swallows the resulting + * `ActionExpiredError`: the person clicks "Connect Gmail" and nothing happens, + * every time, with nothing anywhere to explain it. + * + * Nothing in this flow needs the process that posted the card — the link is + * minted fresh, for the clicker — so cold it must work exactly as warm. + */ +describe("a click that lands after a restart", () => { + it("mints a link from the stored props alone, with no handler carried over", async () => { + const sdk = makeSdk(); + const stored = JSON.parse(JSON.stringify({ toolkit: "gmail" })) as { + toolkit: string; + }; + + const onClick = clickHandlerOf(); + const { interaction, post, postEphemeral } = makeInteraction("U2"); + await onClick(interaction); + + expect(sdk.created[0]?.userId).toBe("U2"); + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(String(postEphemeral.mock.calls[0]?.[1])).toContain(REDIRECT); + // And still never in the thread, cold path or not. + expect(post).not.toHaveBeenCalled(); + }); + + it("tells the clicker privately when the deployment is no longer configured", async () => { + // An operator pulled the key while a card was live. Silence would be + // indistinguishable from the bug above. + delete process.env.COMPOSIO_API_KEY; + const { interaction, post, postEphemeral } = makeInteraction("U2"); + + await handleConnectClick("gmail", interaction); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + const told = String(postEphemeral.mock.calls[0]?.[1]); + expect(told).toContain("not configured"); + // A missing key is permanent. "Try again in a moment" would send the + // clicker into a loop that cannot end and hide whose problem this is. + expect(told).not.toContain("Try again in a moment"); + expect(told).toContain("Retrying will not help"); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tools/composio/__tests__/index.test.ts b/app/tools/composio/__tests__/index.test.ts new file mode 100644 index 0000000..9af8dc3 --- /dev/null +++ b/app/tools/composio/__tests__/index.test.ts @@ -0,0 +1,188 @@ +/** + * Covers index.ts — which tools a deployment exposes, and what an unconfigured + * one costs. + * + * The SDK is mocked so the assertions can see construction itself: the central + * promise of this module is that an unconfigured deployment constructs nothing, + * and that is invisible from the returned array. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Shared across constructions so a test can decide, per user id, whether that + * scope's session comes up at all — which is the only way to see what one + * failing scope costs the others. + */ +const sessionsCreate = vi.fn(); +const getRawComposioTools = vi.fn(); + +const ComposioConstructor = vi.fn(function fakeComposio() { + return { + sessions: { create: sessionsCreate }, + tools: { getRawComposioTools }, + }; +}); + +vi.mock("@composio/core", () => ({ Composio: ComposioConstructor })); + +const { composioTools } = await import("../index.js"); +const { resetComposioClient } = await import("../client.js"); +const { clearSessionCache } = await import("../sessions.js"); + +beforeEach(() => { + resetComposioClient(); + clearSessionCache(); + ComposioConstructor.mockClear(); + sessionsCreate.mockReset(); + getRawComposioTools.mockReset(); + vi.restoreAllMocks(); +}); + +function toolNames(env: NodeJS.ProcessEnv): string[] { + return composioTools(env, "open-tag").map((tool) => tool.name); +} + +describe("composioTools", () => { + it("returns nothing without a key", () => { + expect(composioTools({ COMPOSIO_TOOLKITS: "linear" }, "open-tag")).toEqual( + [], + ); + }); + + it("returns nothing without toolkits", () => { + expect(composioTools({ COMPOSIO_API_KEY: "ak_x" }, "open-tag")).toEqual([]); + }); + + it("constructs no SDK client when unconfigured", () => { + composioTools({}, "open-tag"); + composioTools({ COMPOSIO_TOOLKITS: "linear" }, "open-tag"); + composioTools({ COMPOSIO_API_KEY: "ak_x" }, "open-tag"); + + expect(ComposioConstructor).not.toHaveBeenCalled(); + }); + + it("returns search and run for a workspace-only config", () => { + expect(toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" })).toEqual( + ["search_my_tools", "run_my_tool"], + ); + }); + + it("adds connect_my_app when personal toolkits are configured", () => { + expect( + toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_USER_TOOLKITS: "gmail" }), + ).toContain("connect_my_app"); + }); + + it("omits connect_my_app when every toolkit is shared", () => { + expect( + toolNames({ COMPOSIO_API_KEY: "ak_x", COMPOSIO_TOOLKITS: "linear" }), + ).not.toContain("connect_my_app"); + }); + + /** + * The warnings are a boot-time diagnostic. Emitting them from the per-turn + * path would bury a real misconfiguration under one repetition per message. + */ + it("emits startup warnings once per call, not once per turn", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const tools = composioTools( + { + COMPOSIO_API_KEY: "ak_x", + COMPOSIO_TOOLKITS: "gmail", + COMPOSIO_USER_TOOLKITS: "gmail", + }, + "open-tag", + ); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain( + "is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS", + ); + // The tools exist and were built without warning again. + expect(tools.length).toBeGreaterThan(0); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("never logs the API key", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + composioTools( + { + COMPOSIO_API_KEY: "ak_secret", + COMPOSIO_TOOLKITS: "gmail", + LINEAR_API_KEY: "lin_x", + COMPOSIO_USER_TOOLKITS: "linear", + }, + "open-tag", + ); + + for (const call of warn.mock.calls) { + expect(JSON.stringify(call)).not.toContain("ak_secret"); + } + }); +}); + +/** + * The per-turn scope resolution, reached through `search_my_tools` because that + * is the only door to it — `resolve` is a closure, and the thing worth asserting + * is what a turn gets when one identity's session cannot be created. + */ +describe("a scope whose session fails", () => { + const BOTH = { + COMPOSIO_API_KEY: "ak_secret", + COMPOSIO_TOOLKITS: "linear", + COMPOSIO_USER_TOOLKITS: "gmail", + }; + + /** A session that answers one search with one candidate slug. */ + function workingSession(slug: string) { + return { + sessionId: "trs_1", + search: vi.fn(async () => ({ + results: [{ primaryToolSlugs: [slug] }], + toolSchemas: { [slug]: { description: "d", inputSchema: {} } }, + })), + }; + } + + async function searchAsAlice(): Promise<{ tools: Array<{ slug: string }> }> { + const tool = composioTools(BOTH, "open-tag").find((t) => t.name === "search_my_tools"); + if (!tool) throw new Error("search_my_tools was not exposed"); + return (await tool.handler({ query: "anything" }, { + actor: { id: "U_ALICE" }, + thread: { post: vi.fn() }, + } as never)) as { tools: Array<{ slug: string }> }; + } + + beforeEach(() => { + getRawComposioTools.mockResolvedValue([{ slug: "LINEAR_LIST_ISSUES", tags: ["readOnlyHint"] }]); + // Alice's personal session is the one that cannot be created. + sessionsCreate.mockImplementation(async (userId: string) => { + if (userId === "U_ALICE") throw new Error("connection refused"); + return workingSession("LINEAR_LIST_ISSUES"); + }); + }); + + it("costs only its own tools, not the other scope's", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await searchAsAlice(); + + expect(result.tools.map((t) => t.slug)).toEqual(["LINEAR_LIST_ISSUES"]); + }); + + it("warns, naming the scope and no credential", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await searchAsAlice(); + + const lines = warn.mock.calls.map((call) => String(call[0])); + const reported = lines.filter((line) => line.includes("no session for")); + expect(reported).toHaveLength(1); + expect(reported[0]).toContain("U_ALICE"); + expect(reported[0]).toContain("gmail"); + expect(reported[0]).toContain("connection refused"); + for (const line of lines) expect(line).not.toContain("ak_secret"); + }); +}); diff --git a/app/tools/composio/__tests__/pending.test.ts b/app/tools/composio/__tests__/pending.test.ts new file mode 100644 index 0000000..ac763c0 --- /dev/null +++ b/app/tools/composio/__tests__/pending.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearPending, + mayApprove, + registerPending, + restorePending, + takePending, +} from "../pending.js"; + +function call(overrides: Partial[0]> = {}) { + return { + session: {} as never, + slug: "GMAIL_DELETE_THREAD", + args: { id: "t1" }, + effect: "destructive" as const, + userId: "U_ALICE", + workspaceUserId: "open-tag", + action: "Gmail delete thread", + ...overrides, + }; +} + +beforeEach(() => clearPending()); + +describe("pending registry", () => { + it("round-trips a call by token", () => { + const token = registerPending(call()); + expect(takePending(token)?.slug).toBe("GMAIL_DELETE_THREAD"); + }); + + it("issues distinct tokens", () => { + expect(registerPending(call())).not.toBe(registerPending(call())); + }); + + it("consumes the token so a card cannot be replayed", () => { + const token = registerPending(call()); + takePending(token); + expect(takePending(token)).toBeUndefined(); + }); + + it("returns undefined for an unknown token", () => { + expect(takePending("nope")).toBeUndefined(); + }); + + it("draws the token from the CSPRNG, not from Math.random", () => { + // For a workspace-scope call, holding the token IS the authorization, so + // the ~41 predictable bits of `Math.random()` are not enough to defend a + // delete with. A v4 UUID is 122 bits from `crypto`. + const token = registerPending(call()); + expect(token).toMatch( + /^ctr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("keeps the arguments out of the token itself", () => { + // The token is what travels in the Slack action value. Anything the caller + // passed must stay in this process. + const token = registerPending(call({ args: { body: "secret-payload" } })); + expect(token).not.toContain("secret-payload"); + }); +}); + +describe("bounded registry", () => { + it("evicts the oldest call once the cap is passed", () => { + // Each entry pins a live session and the call's arguments — mail bodies + // included — and this map outlives every conversation. + const first = registerPending(call({ slug: "FIRST" })); + const tokens = [first]; + for (let i = 0; i < 64; i++) tokens.push(registerPending(call({ slug: `T_${i}` }))); + + expect(takePending(first)).toBeUndefined(); + // Only the oldest went: everything after it is still approvable. + for (const token of tokens.slice(1)) expect(takePending(token)).toBeDefined(); + }); + + it("keeps exactly the cap's worth without evicting", () => { + const first = registerPending(call({ slug: "FIRST" })); + for (let i = 0; i < 63; i++) registerPending(call()); + expect(takePending(first)?.slug).toBe("FIRST"); + }); + + it("never evicts the call it was just handed", () => { + for (let i = 0; i < 64; i++) registerPending(call()); + const newest = registerPending(call({ slug: "NEWEST" })); + expect(takePending(newest)?.slug).toBe("NEWEST"); + }); +}); + +describe("restorePending", () => { + it("puts a call back under the same token the live card carries", () => { + const token = registerPending(call()); + const taken = takePending(token); + expect(taken).toBeDefined(); + if (!taken) return; + + restorePending(token, taken); + // A fresh token would strand the call: the posted card still carries the + // original one, so the rightful approver's click would read "expired". + expect(takePending(token)?.slug).toBe("GMAIL_DELETE_THREAD"); + }); + + it("still consumes on the next read", () => { + const token = registerPending(call()); + const taken = takePending(token); + if (!taken) throw new Error("expected a call"); + restorePending(token, taken); + takePending(token); + expect(takePending(token)).toBeUndefined(); + }); +}); + +describe("mayApprove", () => { + it("lets anyone approve a workspace-scope call", () => { + const c = call({ userId: "open-tag" }); + expect(mayApprove(c, "U_BOB")).toBe(true); + }); + + it("lets the originator approve their own personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), "U_ALICE")).toBe(true); + }); + + it("refuses someone else approving a personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), "U_BOB")).toBe(false); + }); + + it("refuses an unidentified clicker on a personal-scope call", () => { + expect(mayApprove(call({ userId: "U_ALICE" }), undefined)).toBe(false); + }); + + it("refuses a blank clicker id on a personal-scope call", () => { + // `""` is not an identity, and `"" === call.userId` must never be the test + // that admits a click. + expect(mayApprove(call({ userId: "U_ALICE" }), "")).toBe(false); + expect(mayApprove(call({ userId: "" }), "")).toBe(false); + }); +}); diff --git a/app/tools/composio/__tests__/run-tool.test.tsx b/app/tools/composio/__tests__/run-tool.test.tsx new file mode 100644 index 0000000..90e250e --- /dev/null +++ b/app/tools/composio/__tests__/run-tool.test.tsx @@ -0,0 +1,492 @@ +/** + * `run_my_tool` is the gate between the model and irreversible action, so the + * tests split in two. + * + * The first half drives `tool.handler` and asserts what reaches Composio and + * what only reaches a card. The second half drives `handleToolRunDecision` — + * the Approve/Cancel click — directly. That handler carries the whole + * authorization story (expired token, wrong approver, provider failure), which + * is why it is an exported function rather than a closure inside the JSX. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToIR, type ChannelNode, type InteractionContext } from "@copilotkit/channels"; +import { createRunTool, handleToolRunDecision } from "../run-tool.js"; +import { clearPending, registerPending, type PendingCall } from "../pending.js"; +import type { CachedSession, RawSession } from "../sessions.js"; +import type { ConfirmDecision } from "../../../human-in-the-loop/index.js"; + +/** Exactly what the SDK hands back — note that a failure resolves, never throws. */ +type ExecuteResult = Awaited>; + +/** What `session.execute` resolves to on a successful run. */ +function okResult(): ExecuteResult { + return { data: { ok: true }, error: null, logId: "log_1" }; +} + +function cached( + execute: RawSession["execute"] = vi.fn(async () => okResult()), + userId = "open-tag", +) { + return { + session: { sessionId: "trs_1", execute }, + userId, + effects: new Map([ + ["GMAIL_SEND_EMAIL", "write"], + ["GMAIL_DELETE_THREAD", "destructive"], + ["GMAIL_FETCH_EMAILS", "read"], + ]), + toolkits: ["gmail"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +function makeCtx() { + const post = vi.fn(); + return { ctx: { actor: { id: "U1" }, thread: { post } } as never, post }; +} + +/** Children of an IR node as an array (empty if none). */ +function childNodes(node: ChannelNode): ChannelNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as ChannelNode[]; + if (children && typeof children === "object" && "type" in (children as object)) { + return [children as ChannelNode]; + } + return []; +} + +function findAll(nodes: ChannelNode[], type: string): ChannelNode[] { + const out: ChannelNode[] = []; + for (const node of nodes) { + if (node.type === type) out.push(node); + out.push(...findAll(childNodes(node), type)); + } + return out; +} + +/** Every descendant `text` node's text, concatenated depth-first. */ +function collectText(node: ChannelNode): string { + if (node.type === "text") return String(node.props?.value ?? ""); + return childNodes(node).map(collectText).join(""); +} + +/** The approval token a posted card carries in its buttons. */ +function tokenOf(renderable: unknown): string { + const button = findAll(renderToIR(renderable as never), "button")[0]; + const value = button?.props?.value as ConfirmDecision | undefined; + if (!value?.token) throw new Error("posted card carries no token"); + return value.token; +} + +function textOf(renderable: unknown): string { + return renderToIR(renderable as never) + .map(collectText) + .join(" "); +} + +beforeEach(() => clearPending()); + +describe("run_my_tool", () => { + it("runs a read immediately without posting a card", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_FETCH_EMAILS", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalledWith("GMAIL_FETCH_EMAILS", {}); + }); + + it("runs a write immediately in destructive mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: { to: "a@b.c" } }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("posts a card and does NOT execute a destructive tool", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + const result = await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + expect(String(result)).toContain("Stop here"); + }); + + it("posts a card for any write in writes mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "writes", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + }); + + it("never posts a card in off mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("gates an unmapped slug in writes mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "writes", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: {} }, ctx); + expect(post).toHaveBeenCalledTimes(1); + expect(entry.session.execute).not.toHaveBeenCalled(); + }); + + it("gates an unmapped slug in destructive mode, the default", async () => { + // The effect map is filled with `limit: 300`, so a real slug past the cap + // is unmapped, as is anything hallucinated or injected. Calling those + // "writes" would run them unapproved in the mode that ships by default. + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: { id: "x" } }, ctx); + expect(entry.session.execute).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("still runs a classified write unapproved in destructive mode", async () => { + // The other half of the rule: gating the unmapped case must not quietly + // gate every write and turn `destructive` mode into `writes` mode. + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(entry.session.execute).toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it("runs an unmapped slug in off mode", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + await tool.handler({ slug: "MYSTERY_TOOL", args: {} }, ctx); + expect(post).not.toHaveBeenCalled(); + expect(entry.session.execute).toHaveBeenCalled(); + }); + + it("routes to the scope holding the slug", async () => { + const other = cached(); + other.effects = new Map([["LINEAR_LIST_ISSUES", "read"]]) as never; + const gmail = cached(); + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [other, gmail], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_FETCH_EMAILS", args: {} }, ctx); + expect(gmail.session.execute).toHaveBeenCalled(); + expect(other.session.execute).not.toHaveBeenCalled(); + }); + + it("surfaces a returned error rather than treating it as success", async () => { + const entry = cached( + vi.fn(async () => ({ data: {}, error: "recipient_email must be a string", logId: "log_9" })), + ); + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [entry], "off", "open-tag"); + const result = await tool.handler({ slug: "GMAIL_SEND_EMAIL", args: {} }, ctx); + expect(String(result)).toContain("recipient_email must be a string"); + }); + + it("returns an error string when nothing resolves", async () => { + const { ctx } = makeCtx(); + const tool = createRunTool(async () => [], "off", "open-tag"); + expect(String(await tool.handler({ slug: "X", args: {} }, ctx))).toContain("not configured"); + }); + + it("puts only the token in the button payload, never the arguments", async () => { + const entry = cached(); + const { ctx, post } = makeCtx(); + const tool = createRunTool(async () => [entry], "destructive", "open-tag"); + await tool.handler( + { slug: "GMAIL_DELETE_THREAD", args: { id: "t1", body: "secret-payload" } }, + ctx, + ); + + const buttons = findAll(renderToIR(post.mock.calls[0]?.[0] as never), "button"); + expect(buttons.length).toBeGreaterThan(0); + for (const button of buttons) { + const value = button.props?.value as ConfirmDecision; + // Slack action values are size-capped and must not carry user data. + expect(Object.keys(value).sort()).toEqual(["approved", "token"]); + expect(JSON.stringify(value)).not.toContain("secret-payload"); + } + }); +}); + +/** A pending call plus the `execute` spy behind its session. */ +function pending(overrides: Partial = {}, result: ExecuteResult = okResult()) { + const execute = vi.fn(async () => result); + const call: PendingCall = { + session: { sessionId: "trs_1", execute } as unknown as RawSession, + slug: "GMAIL_DELETE_THREAD", + args: { id: "t1" }, + effect: "destructive", + userId: "U_ALICE", + workspaceUserId: "open-tag", + action: "Gmail delete thread", + ...overrides, + }; + return { call, token: registerPending(call), execute }; +} + +function click(value: ConfirmDecision | undefined, clickerId: string | undefined) { + const update = vi.fn(); + const post = vi.fn(); + const interaction = { + thread: { update, post }, + message: { ref: { id: "m1" } }, + action: { id: "act_1", value }, + values: {}, + user: null, + actor: clickerId === undefined ? undefined : { id: clickerId, kind: "human" }, + platform: "slack", + } as unknown as InteractionContext; + return { interaction, update, post }; +} + +describe("handleToolRunDecision", () => { + it("reports an unknown token as expired and executes nothing", async () => { + const { execute } = pending(); + const { interaction, update } = click({ token: "ctr_gone", approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("treats a token consumed by an earlier click as expired", async () => { + const { token, execute } = pending(); + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(execute).toHaveBeenCalledTimes(1); + + const replay = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(replay.interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledTimes(1); + expect(textOf(replay.update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("ignores a click carrying no decision", async () => { + const { execute } = pending(); + const { interaction, update, post } = click(undefined, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it("refuses someone else's approval and leaves the request standing", async () => { + const { token, execute } = pending({ userId: "U_ALICE" }); + const bob = click({ token, approved: true }, "U_BOB"); + + await handleToolRunDecision(bob.interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + // The card keeps its buttons — refusing Bob must not cancel Alice's request. + expect(bob.update).not.toHaveBeenCalled(); + expect(String(bob.post.mock.calls[0]?.[0])).toContain("Only the person who asked"); + + // And the same card, clicked by Alice, still works. + const alice = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(alice.interaction, "Gmail delete thread"); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("refuses an unidentified clicker on a personal-scope call", async () => { + const { token, execute } = pending({ userId: "U_ALICE" }); + const { interaction, post } = click({ token, approved: true }, undefined); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(String(post.mock.calls[0]?.[0])).toContain("Only the person who asked"); + }); + + it("lets anyone approve a workspace-scope call", async () => { + const { token, execute } = pending({ userId: "open-tag" }); + const { interaction } = click({ token, approved: true }, "U_BOB"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("executes nothing on a decline and says so on the card", async () => { + const { token, execute } = pending(); + const { interaction, update } = click({ token, approved: false }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("Cancelled"); + }); + + it("runs the approved call with the arguments held in process", async () => { + const { token, execute } = pending({ args: { id: "t1", body: "secret-payload" } }); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).toHaveBeenCalledWith("GMAIL_DELETE_THREAD", { + id: "t1", + body: "secret-payload", + }); + expect(textOf(update.mock.calls[0]?.[1])).toContain("Done"); + }); + + it("puts a returned error on the card instead of reporting success", async () => { + // `execute` resolves rather than throws on failure, so a try/catch alone + // would rewrite the card to "Done." after deleting nothing. + const { token } = pending({}, { data: {}, error: "Invalid request data provided", logId: "l" }); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + const text = textOf(update.mock.calls[0]?.[1]); + expect(text).toContain("Invalid request data provided"); + expect(text).not.toContain("Done."); + }); + + it("reports a transport-level throw on the card instead of freezing it", async () => { + // The token is consumed before `execute` runs, so a throw that escaped + // would leave a card with dead buttons and no result at all. + const { call } = pending(); + const execute = vi.fn(async () => { + throw new Error("socket hang up"); + }); + call.session = { sessionId: "trs_1", execute } as unknown as RawSession; + const token = registerPending(call); + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(update).toHaveBeenCalledTimes(1); + const text = textOf(update.mock.calls[0]?.[1]); + expect(text).toContain("socket hang up"); + expect(text).not.toContain("Done."); + }); + + it("gives an empty token the same answer as an expired one", async () => { + const { execute } = pending(); + const { interaction, update } = click({ token: "", approved: true }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("reports an evicted card as expired rather than crashing", async () => { + const { token, execute } = pending(); + for (let i = 0; i < 64; i++) pending({ slug: `FILLER_${i}` }); + + const { interaction, update } = click({ token, approved: true }, "U_ALICE"); + await handleToolRunDecision(interaction, "Gmail delete thread"); + + expect(execute).not.toHaveBeenCalled(); + expect(textOf(update.mock.calls[0]?.[1])).toContain("expired"); + }); + + it("names the pending call's own action, not the label passed by the caller", async () => { + const { token } = pending({ action: "Gmail delete thread" }); + const { interaction, update } = click({ token, approved: false }, "U_ALICE"); + + await handleToolRunDecision(interaction, "Some other card"); + + expect(textOf(update.mock.calls[0]?.[1])).toContain("Gmail delete thread"); + }); +}); + +describe("scope identity decides who may approve", () => { + /** Gate one destructive call and hand back the token its card carries. */ + async function gate(scope: CachedSession, actorId: string) { + const post = vi.fn(); + const tool = createRunTool(async () => [scope], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: actorId }, + thread: { post }, + } as never); + return tokenOf(post.mock.calls[0]?.[0]); + } + + it("binds a card routed to a personal scope to that scope's owner", async () => { + const entry = cached(undefined, "U_ALICE"); + const token = await gate(entry, "U_ALICE"); + + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(entry.session.execute).not.toHaveBeenCalled(); + + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(entry.session.execute).toHaveBeenCalledTimes(1); + }); + + it("lets a colleague approve a workspace-scope card a named person asked for", async () => { + // Alice sent the message, but the slug routed to the shared account. The + // call is the thread's, not hers, so Bob approving it touches nothing of + // his own — binding it to the actor would strand it behind one person. + const entry = cached(undefined, "open-tag"); + const token = await gate(entry, "U_ALICE"); + + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(entry.session.execute).toHaveBeenCalledTimes(1); + }); + + it("routes an unmapped personal slug by toolkit, not to the first scope", async () => { + // The effect map is filled with `limit: 300`, so a real slug can be absent + // from it. Falling back to `scopes[0]` would run Alice's gmail call through + // the shared session — which does not carry gmail — and bind its card to + // `workspaceUserId`, letting anyone in the thread approve it. + const workspace = cached(undefined, "open-tag"); + workspace.effects = new Map() as never; + workspace.toolkits = ["linear"] as never; + const personal = cached(undefined, "U_ALICE"); + personal.effects = new Map() as never; + + const post = vi.fn(); + const tool = createRunTool(async () => [workspace, personal], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: "U_ALICE" }, + thread: { post }, + } as never); + + const token = tokenOf(post.mock.calls[0]?.[0]); + // Bound to Alice: Bob's click does nothing, hers executes — and against the + // personal session, never the shared one. + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(personal.session.execute).not.toHaveBeenCalled(); + + await handleToolRunDecision(click({ token, approved: true }, "U_ALICE").interaction, "x"); + expect(personal.session.execute).toHaveBeenCalledTimes(1); + expect(workspace.session.execute).not.toHaveBeenCalled(); + }); + + it("picks the identity of the scope the slug routed to, not the first scope", async () => { + const workspace = cached(undefined, "open-tag"); + workspace.effects = new Map([["LINEAR_LIST_ISSUES", "read"]]) as never; + const personal = cached(undefined, "U_ALICE"); + const post = vi.fn(); + const tool = createRunTool(async () => [workspace, personal], "destructive", "open-tag"); + await tool.handler({ slug: "GMAIL_DELETE_THREAD", args: { id: "t1" } }, { + actor: { id: "U_ALICE" }, + thread: { post }, + } as never); + + const token = tokenOf(post.mock.calls[0]?.[0]); + await handleToolRunDecision(click({ token, approved: true }, "U_BOB").interaction, "x"); + expect(personal.session.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tools/composio/__tests__/scopes.test.ts b/app/tools/composio/__tests__/scopes.test.ts new file mode 100644 index 0000000..689461b --- /dev/null +++ b/app/tools/composio/__tests__/scopes.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { resolveScopes, startupWarnings } from "../scopes.js"; +import type { ComposioConfig } from "../config.js"; + +function config(overrides: Partial = {}): ComposioConfig { + return { + apiKey: "ak_x", + workspaceToolkits: [], + userToolkits: [], + approvals: "destructive", + workspaceUserId: "open-tag", + authConfigs: {}, + ...overrides, + }; +} + +describe("resolveScopes", () => { + it("returns the workspace scope when only it is configured", () => { + expect(resolveScopes(config({ workspaceToolkits: ["linear"] }), { id: "U1" })).toEqual([ + { userId: "open-tag", toolkits: ["linear"] }, + ]); + }); + + it("returns the personal scope when only it is configured", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: "U1" })).toEqual([ + { userId: "U1", toolkits: ["gmail"] }, + ]); + }); + + it("returns BOTH when both are configured", () => { + const scopes = resolveScopes( + config({ workspaceToolkits: ["linear"], userToolkits: ["gmail"] }), + { id: "U1" }, + ); + expect(scopes).toEqual([ + { userId: "open-tag", toolkits: ["linear"] }, + { userId: "U1", toolkits: ["gmail"] }, + ]); + }); + + it("drops the personal scope without a verified actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["linear"], userToolkits: ["gmail"] }), undefined), + ).toEqual([{ userId: "open-tag", toolkits: ["linear"] }]); + }); + + it("returns nothing when personal-only and there is no actor", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), undefined)).toEqual([]); + }); + + it("drops a toolkit from the workspace scope when it is also personal", () => { + const scopes = resolveScopes( + config({ workspaceToolkits: ["linear", "jira"], userToolkits: ["linear"] }), + { id: "U1" }, + ); + expect(scopes).toEqual([ + { userId: "open-tag", toolkits: ["jira"] }, + { userId: "U1", toolkits: ["linear"] }, + ]); + }); + + it("does not fall back to the shared identity for a dual-listed toolkit", () => { + expect( + resolveScopes( + config({ workspaceToolkits: ["gmail", "jira"], userToolkits: ["gmail"] }), + undefined, + ), + ).toEqual([{ userId: "open-tag", toolkits: ["jira"] }]); + }); + + it("returns nothing when de-duplication empties the shared list and there is no actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), undefined), + ).toEqual([]); + }); + + it("drops the personal scope for an actor with an empty id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: "" })).toEqual([]); + }); + + it("drops the personal scope for an actor with no id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), {})).toEqual([]); + }); + + it("drops the personal scope for an actor with a whitespace-only id", () => { + expect(resolveScopes(config({ userToolkits: ["gmail"] }), { id: " " })).toEqual([]); + }); + + it("never routes a personal toolkit to the shared identity for an idless actor", () => { + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), { id: "" }), + ).toEqual([]); + expect( + resolveScopes(config({ workspaceToolkits: ["gmail"], userToolkits: ["gmail"] }), {}), + ).toEqual([]); + }); +}); + +describe("startupWarnings", () => { + it("is silent on a clean config", () => { + expect(startupWarnings(config({ workspaceToolkits: ["linear"] }), {})).toEqual([]); + }); + + it("warns when a toolkit is in both lists", () => { + const warnings = startupWarnings( + config({ workspaceToolkits: ["linear"], userToolkits: ["linear"] }), + {}, + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("linear"); + expect(warnings[0]).toContain("own account"); + }); + + it("warns when a personal app sits in the shared list", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["gmail"] }), {}); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("COMPOSIO_USER_TOOLKITS"); + }); + + it("warns when a toolkit is also configured over MCP", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["linear"] }), { + LINEAR_API_KEY: "lin_x", + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("LINEAR_API_KEY"); + }); + + it("warns once for a toolkit listed twice in the shared list", () => { + const warnings = startupWarnings(config({ workspaceToolkits: ["gmail", "gmail"] }), {}); + expect(warnings).toHaveLength(1); + }); + + it("is silent when only the MCP integration is configured", () => { + expect(startupWarnings(config({ workspaceToolkits: ["jira"] }), { LINEAR_API_KEY: "x" })).toEqual( + [], + ); + }); +}); diff --git a/app/tools/composio/__tests__/search-tool.test.ts b/app/tools/composio/__tests__/search-tool.test.ts new file mode 100644 index 0000000..8d3c6a2 --- /dev/null +++ b/app/tools/composio/__tests__/search-tool.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSearchTool } from "../search-tool.js"; +import type { CachedSession } from "../sessions.js"; + +function cached(searchResult: unknown): CachedSession { + return { + session: { sessionId: "trs_1", search: vi.fn(async () => searchResult) }, + effects: new Map(), + toolkits: ["gmail"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +/** A scope whose Composio session is broken. */ +function failing(): CachedSession { + return { + session: { + sessionId: "trs_dead", + search: vi.fn(async () => { + throw new Error("session expired"); + }), + }, + effects: new Map(), + toolkits: ["linear"], + filledAt: Date.now(), + } as unknown as CachedSession; +} + +/** A one-result scope offering `slugs`, each with a trivial input schema. */ +function scopeOffering(...slugs: string[]): CachedSession { + return cached({ + results: [{ primaryToolSlugs: slugs, relatedToolSlugs: [] }], + toolSchemas: Object.fromEntries( + slugs.map((s) => [s, { description: "d", inputSchema: { type: "object" } }]), + ), + toolkitConnectionStatuses: [], + }); +} + +const ctx = { actor: { id: "U1" } } as never; + +describe("search_my_tools", () => { + it("returns slugs with their schemas", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["GMAIL_SEND_EMAIL"], relatedToolSlugs: [] }], + toolSchemas: { + GMAIL_SEND_EMAIL: { description: "Sends an email.", inputSchema: { type: "object" } }, + }, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: true }], + }), + ]); + const result = (await tool.handler({ query: "send email" }, ctx)) as { + tools: Array<{ slug: string; inputSchema: unknown }>; + }; + expect(result.tools[0]?.slug).toBe("GMAIL_SEND_EMAIL"); + expect(result.tools[0]?.inputSchema).toEqual({ type: "object" }); + }); + + it("merges results across scopes", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["LINEAR_LIST_ISSUES"], relatedToolSlugs: [] }], + toolSchemas: { LINEAR_LIST_ISSUES: { description: "d", inputSchema: {} } }, + toolkitConnectionStatuses: [], + }), + cached({ + results: [{ primaryToolSlugs: ["GMAIL_SEND_EMAIL"], relatedToolSlugs: [] }], + toolSchemas: { GMAIL_SEND_EMAIL: { description: "d", inputSchema: {} } }, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["LINEAR_LIST_ISSUES", "GMAIL_SEND_EMAIL"]); + }); + + it("reports toolkits that still need connecting", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: false }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual(["gmail"]); + }); + + it("caps results at five", async () => { + const slugs = Array.from({ length: 12 }, (_, i) => `T_${i}`); + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: slugs, relatedToolSlugs: [] }], + toolSchemas: Object.fromEntries( + slugs.map((s) => [s, { description: "d", inputSchema: {} }]), + ), + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: unknown[] }; + expect(result.tools).toHaveLength(5); + }); + + it("returns an error string when nothing resolves", async () => { + const tool = createSearchTool(async () => []); + expect(await tool.handler({ query: "x" }, ctx)).toContain("not configured"); + }); + + it("gives every scope a share of the cap instead of letting the first fill it", async () => { + // The shared workspace scope alone could fill all five slots. If it did, + // the person's own calendar would be unreachable. + const tool = createSearchTool(async () => [ + scopeOffering("W_0", "W_1", "W_2", "W_3", "W_4", "W_5", "W_6"), + scopeOffering("P_0", "P_1", "P_2", "P_3", "P_4", "P_5", "P_6"), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + const slugs = result.tools.map((t) => t.slug); + + expect(slugs).toHaveLength(5); + expect(slugs.some((s) => s.startsWith("W_"))).toBe(true); + expect(slugs.some((s) => s.startsWith("P_"))).toBe(true); + // Round-robin, so the two scopes alternate by rank. + expect(slugs).toEqual(["W_0", "P_0", "W_1", "P_1", "W_2"]); + }); + + it("keeps each scope's own ranking: primary slugs before related ones", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["A_PRIMARY"], relatedToolSlugs: ["A_RELATED"] }], + toolSchemas: { + A_PRIMARY: { description: "d", inputSchema: {} }, + A_RELATED: { description: "d", inputSchema: {} }, + }, + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["A_PRIMARY", "A_RELATED"]); + }); + + it("still answers from the healthy scope when another scope's search rejects", async () => { + const tool = createSearchTool(async () => [failing(), scopeOffering("GMAIL_SEND_EMAIL")]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["GMAIL_SEND_EMAIL"]); + }); + + it("returns an empty result rather than throwing when every scope rejects", async () => { + const tool = createSearchTool(async () => [failing(), failing()]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result.tools).toEqual([]); + expect(result.needsConnection).toEqual([]); + }); + + it("searches the scopes in parallel", async () => { + // The first scope only finishes once the second has started, so a + // sequential implementation deadlocks and this test times out. + let releaseFirst!: () => void; + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = cached(undefined) as unknown as { session: { search: () => Promise } }; + first.session.search = async () => { + await secondStarted; + return { + results: [{ primaryToolSlugs: ["A"] }], + toolSchemas: { A: { inputSchema: {} } }, + }; + }; + const second = cached(undefined) as unknown as { session: { search: () => Promise } }; + second.session.search = async () => { + releaseFirst(); + return { + results: [{ primaryToolSlugs: ["B"] }], + toolSchemas: { B: { inputSchema: {} } }, + }; + }; + + const tool = createSearchTool(async () => [ + first as unknown as CachedSession, + second as unknown as CachedSession, + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["A", "B"]); + }); + + it("does not treat a connected toolkit as needing connection", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: true }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual([]); + }); + + it("does not treat an absent hasActiveConnection as needing connection", async () => { + // Silence is not a negative answer. Reporting it would prompt the user to + // reconnect an app that is already working. + const tool = createSearchTool(async () => [ + cached({ + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail" }], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual([]); + }); + + it("reports a disconnected toolkit once even when several scopes see it", async () => { + const disconnected = { + results: [], + toolSchemas: {}, + toolkitConnectionStatuses: [{ toolkit: "gmail", hasActiveConnection: false }], + }; + const tool = createSearchTool(async () => [cached(disconnected), cached(disconnected)]); + const result = (await tool.handler({ query: "x" }, ctx)) as { needsConnection: string[] }; + expect(result.needsConnection).toEqual(["gmail"]); + }); + + it("returns a slug missing from toolSchemas with a null inputSchema", async () => { + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: ["GMAIL_MYSTERY"], relatedToolSlugs: [] }], + toolSchemas: {}, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: Array<{ slug: string; description: string; inputSchema: unknown }>; + }; + expect(result.tools).toEqual([ + { slug: "GMAIL_MYSTERY", description: "", inputSchema: null }, + ]); + }); + + it("never lets an uncallable candidate displace a callable one", async () => { + const schemaless = Array.from({ length: 5 }, (_, i) => `NO_SCHEMA_${i}`); + const tool = createSearchTool(async () => [ + cached({ + results: [{ primaryToolSlugs: [...schemaless, "HAS_SCHEMA"], relatedToolSlugs: [] }], + toolSchemas: { HAS_SCHEMA: { description: "d", inputSchema: { type: "object" } } }, + toolkitConnectionStatuses: [], + }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + // Ranked last by the scope, but the only one the model can actually call. + expect(result.tools[0]?.slug).toBe("HAS_SCHEMA"); + expect(result.tools).toHaveLength(5); + }); + + it("returns one entry when two scopes offer the same slug", async () => { + const tool = createSearchTool(async () => [ + scopeOffering("GMAIL_SEND_EMAIL"), + scopeOffering("GMAIL_SEND_EMAIL"), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: Array<{ slug: string }> }; + expect(result.tools.map((t) => t.slug)).toEqual(["GMAIL_SEND_EMAIL"]); + }); + + it("tolerates a response with every field absent", async () => { + const tool = createSearchTool(async () => [cached({})]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); + + it("tolerates a result whose slug arrays are absent", async () => { + const tool = createSearchTool(async () => [cached({ results: [{}] })]); + const result = (await tool.handler({ query: "x" }, ctx)) as { tools: unknown[] }; + expect(result.tools).toEqual([]); + }); + + it("tolerates a null response", async () => { + const tool = createSearchTool(async () => [cached(null)]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); + + it("tolerates malformed field types", async () => { + const tool = createSearchTool(async () => [ + cached({ results: "nope", toolSchemas: "nope", toolkitConnectionStatuses: "nope" }), + ]); + const result = (await tool.handler({ query: "x" }, ctx)) as { + tools: unknown[]; + needsConnection: string[]; + }; + expect(result).toEqual({ tools: [], needsConnection: [] }); + }); +}); diff --git a/app/tools/composio/__tests__/sessions.test.ts b/app/tools/composio/__tests__/sessions.test.ts new file mode 100644 index 0000000..e412abf --- /dev/null +++ b/app/tools/composio/__tests__/sessions.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearSessionCache, getSession, invalidateSession } from "../sessions.js"; + +function makeSdk() { + // The parameters are declared even though the fake ignores them: without them + // vitest infers an empty argument tuple and `mock.calls` cannot be indexed. + const create = vi.fn(async (_userId: string, _options: Record) => ({ + sessionId: "trs_1", + search: vi.fn(), + execute: vi.fn(), + authorize: vi.fn(), + toolkits: vi.fn(), + })); + const getRawComposioTools = vi.fn(async () => [ + { slug: "LINEAR_LIST_ISSUES", tags: ["readOnlyHint"] }, + { slug: "LINEAR_CREATE_ISSUE", tags: ["createHint"] }, + { slug: "LINEAR_DELETE_ISSUE", tags: ["destructiveHint"] }, + ]); + return { sessions: { create }, tools: { getRawComposioTools } }; +} + +beforeEach(() => clearSessionCache()); + +describe("getSession", () => { + it("always disables the remote workbench, on every session it creates", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + await getSession(sdk, "U2", ["gmail"]); + + const calls = sdk.sessions.create.mock.calls; + expect(calls).toHaveLength(2); + // Not `toHaveBeenCalledWith`, which passes when any single call matches. A + // session without this flag exposes remote bash and a Python sandbox, so the + // constraint has to hold for every call site, not just one of them. + for (const [, options] of calls) { + expect(options).toMatchObject({ workbench: { enable: false } }); + } + }); + + it("passes the requested toolkits", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledWith( + "open-tag", + expect.objectContaining({ toolkits: ["linear"] }), + ); + }); + + it("records whose identity the session acts as", async () => { + // The approval gate reads this to decide who may approve a call routed to + // this scope, so a session that does not know whose it is cannot be gated. + const sdk = makeSdk(); + expect((await getSession(sdk, "open-tag", ["linear"])).userId).toBe("open-tag"); + expect((await getSession(sdk, "U_ALICE", ["gmail"])).userId).toBe("U_ALICE"); + }); + + it("builds an effect map from tool tags", async () => { + const sdk = makeSdk(); + const cached = await getSession(sdk, "open-tag", ["linear"]); + expect(cached.effects.get("LINEAR_LIST_ISSUES")).toBe("read"); + expect(cached.effects.get("LINEAR_CREATE_ISSUE")).toBe("write"); + expect(cached.effects.get("LINEAR_DELETE_ISSUE")).toBe("destructive"); + }); + + it("reuses a cached session for the same user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "open-tag", ["linear"]); + await getSession(sdk, "open-tag", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + }); + + it("keeps separate sessions per user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + await getSession(sdk, "U2", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("keeps separate cache entries per toolkit set for one user", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + await getSession(sdk, "U1", ["linear"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + it("rebuilds after invalidation", async () => { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + invalidateSession("U1"); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + }); + + // The TTL is 10 minutes and the module reads the clock with `Date.now()`, which + // vitest's fake timers replace. The pair matters: the "before" case proves the + // cache is really caching, so the "after" case is evidence of expiry rather + // than of a cache that never hit in the first place. + it("still reuses the session just before the TTL lapses", async () => { + vi.useFakeTimers(); + try { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + vi.advanceTimersByTime(9 * 60 * 1000); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("rebuilds the session after the TTL lapses", async () => { + vi.useFakeTimers(); + try { + const sdk = makeSdk(); + await getSession(sdk, "U1", ["gmail"]); + vi.advanceTimersByTime(11 * 60 * 1000); + await getSession(sdk, "U1", ["gmail"]); + expect(sdk.sessions.create).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/tools/composio/classify.ts b/app/tools/composio/classify.ts new file mode 100644 index 0000000..920377d --- /dev/null +++ b/app/tools/composio/classify.ts @@ -0,0 +1,24 @@ +/** + * Effect classification from Composio's MCP behavior tags. + * + * `ToolSchema.tags` is a generic `string[]` defaulting to empty, so an empty + * array cannot be distinguished from "nobody classified this". Anything not + * positively marked read-only is treated as a write — it fails safe, matching + * `agent/internal_sources.py:139`. + */ +import type { ApprovalMode } from "./config.js"; + +export type Effect = "read" | "write" | "destructive"; + +export function effectOf(tags: string[] | undefined): Effect { + const set = new Set(tags ?? []); + if (set.has("destructiveHint")) return "destructive"; + if (set.has("readOnlyHint")) return "read"; + return "write"; +} + +export function needsApproval(effect: Effect, mode: ApprovalMode): boolean { + if (mode === "off") return false; + if (mode === "destructive") return effect === "destructive"; + return effect !== "read"; +} diff --git a/app/tools/composio/client.ts b/app/tools/composio/client.ts new file mode 100644 index 0000000..1d058b7 --- /dev/null +++ b/app/tools/composio/client.ts @@ -0,0 +1,29 @@ +/** + * The Composio SDK client, constructed lazily. Nothing here runs unless + * `readComposioConfig` returned a config, so an unconfigured deployment never + * instantiates the SDK. + */ +import { Composio } from "@composio/core"; +import type { ComposioConfig } from "./config.js"; +import type { ComposioSdk } from "./sessions.js"; + +let instance: ComposioSdk | null = null; + +export function resetComposioClient(): void { + instance = null; +} + +export function composioClient(config: ComposioConfig): ComposioSdk { + if (!instance) { + instance = new Composio({ + apiKey: config.apiKey, + // Default telemetry registers its own SIGINT/SIGTERM handlers that + // re-raise the signal (Telemetry.ts:349), which truncates the graceful + // shutdown in server.ts:125. Also: this is a self-hosted product whose + // operator never opted into third-party analytics. + allowTracking: false, + disableVersionCheck: true, + }) as unknown as ComposioSdk; + } + return instance; +} diff --git a/app/tools/composio/config.ts b/app/tools/composio/config.ts new file mode 100644 index 0000000..e7e07bc --- /dev/null +++ b/app/tools/composio/config.ts @@ -0,0 +1,86 @@ +/** + * Composio configuration. Absent `COMPOSIO_API_KEY` returns null and the feature + * is never constructed — absent, not disabled, so the agent has no tool it can + * see but must not call. + */ + +export type ApprovalMode = "off" | "destructive" | "writes"; + +const APPROVAL_MODES: ApprovalMode[] = ["off", "destructive", "writes"]; + +export interface ComposioConfig { + apiKey: string; + workspaceToolkits: string[]; + userToolkits: string[]; + approvals: ApprovalMode; + workspaceUserId: string; + /** + * Rarely needed, and read only by `scripts/composio-connect.ts` — no runtime + * path consumes it. `session.authorize()` takes no auth config id and + * resolves one from the project itself, so a personal toolkit with several + * cannot be pinned; this pins the choice when the operator connects a shared + * toolkit. + */ + authConfigs: Record; +} + +function slugList(raw: string | undefined): string[] { + return (raw ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Empty or whitespace-only means unset, matching `readEnvironment`'s + * `?.trim() ||` idiom — `COMPOSIO_APPROVALS=` is routine in `.env` files and + * compose passthrough, and must not take the runtime down at boot. + */ +function approvalMode(raw: string | undefined): ApprovalMode { + const value = raw?.trim().toLowerCase() || "destructive"; + if (!APPROVAL_MODES.includes(value as ApprovalMode)) { + throw new Error( + `Invalid COMPOSIO_APPROVALS: "${raw}" — expected one of ${APPROVAL_MODES.join(", ")}`, + ); + } + return value as ApprovalMode; +} + +/** + * Parses `toolkit:auth_config_id` pairs. Toolkit keys are lowercased to match + * the toolkit lists, but ids are preserved verbatim — real auth config ids are + * mixed case (`ac_ExAmPle1-aB`), so lowercasing them would not resolve. Splits + * on the first colon only, so an id containing one is not truncated. + */ +function authConfigMap(raw: string | undefined): Record { + const pairs: Record = {}; + for (const entry of (raw ?? "").split(",")) { + const separator = entry.indexOf(":"); + if (separator === -1) continue; + const toolkit = entry.slice(0, separator).trim().toLowerCase(); + const id = entry.slice(separator + 1).trim(); + if (toolkit && id) pairs[toolkit] = id; + } + return pairs; +} + +export function readComposioConfig( + env: NodeJS.ProcessEnv, + defaultUserId: string, +): ComposioConfig | null { + const apiKey = env.COMPOSIO_API_KEY?.trim(); + if (!apiKey) return null; + + const workspaceToolkits = slugList(env.COMPOSIO_TOOLKITS); + const userToolkits = slugList(env.COMPOSIO_USER_TOOLKITS); + if (workspaceToolkits.length === 0 && userToolkits.length === 0) return null; + + return { + apiKey, + workspaceToolkits, + userToolkits, + approvals: approvalMode(env.COMPOSIO_APPROVALS), + workspaceUserId: env.COMPOSIO_WORKSPACE_USER_ID?.trim() || defaultUserId, + authConfigs: authConfigMap(env.COMPOSIO_AUTH_CONFIGS), + }; +} diff --git a/app/tools/composio/connect-link.ts b/app/tools/composio/connect-link.ts new file mode 100644 index 0000000..c9f22f7 --- /dev/null +++ b/app/tools/composio/connect-link.ts @@ -0,0 +1,120 @@ +/** + * The decisions behind `scripts/composio-connect.ts`, kept out of the script so + * they can be tested without a network. + * + * Shared toolkits have no in-Slack connect path — `connect_my_app` refuses them + * on purpose, because a connection made by a clicker binds to the clicker's own + * Composio `user_id` while every shared call resolves under + * `workspaceUserId`. The operator script is the only correct path, and these + * two functions are the whole of what it has to get right before it touches the + * API. + */ +import type { ComposioConfig } from "./config.js"; + +/** + * Structural subset of an `authConfigs.list()` item. Declared here rather than + * imported so a test can build one, matching how `sessions.ts` treats the SDK. + */ +export interface AuthConfigSummary { + id: string; + toolkit?: { slug?: string }; + isComposioManaged?: boolean; +} + +/** Either a decision, or the exact sentence the operator should read. */ +export type Decision = { ok: true; value: T } | { ok: false; message: string }; + +export const DASHBOARD_URL = "https://app.composio.dev"; + +/** + * Is this a slug the script may mint a shared link for? + * + * A personal toolkit is refused rather than quietly handled: minting a + * workspace-identity link for `gmail` when `gmail` is in + * `COMPOSIO_USER_TOOLKITS` would connect one mailbox that every personal-scope + * call then ignores, which is the same broken end state this script exists to + * prevent. + */ +export function resolveSharedToolkit( + config: Pick, + requested: string | undefined, +): Decision { + const slug = requested?.trim().toLowerCase(); + if (!slug) { + return { + ok: false, + message: + "Usage: pnpm composio:connect \n" + + `Shared toolkits on this deployment: ${describeList(config.workspaceToolkits)}`, + }; + } + + if (config.workspaceToolkits.includes(slug)) return { ok: true, value: slug }; + + if (config.userToolkits.includes(slug)) { + return { + ok: false, + message: + `"${slug}" is a personal toolkit (COMPOSIO_USER_TOOLKITS). Each person connects ` + + "their own from inside a thread — ask the agent for it and click the Connect " + + "card. This script only connects shared toolkits.", + }; + } + + return { + ok: false, + message: + `"${slug}" is not in COMPOSIO_TOOLKITS. Add it there (and at ${DASHBOARD_URL}) ` + + `and restart the runtime first.\n` + + `Shared toolkits on this deployment: ${describeList(config.workspaceToolkits)}`, + }; +} + +function describeList(slugs: string[]): string { + return slugs.length > 0 ? slugs.join(", ") : "(none configured)"; +} + +/** + * Which auth config the Connect Link should be minted against. + * + * A pin wins outright and is never cross-checked against the listing: pinning + * is how an operator overrides a bad guess, so second-guessing it would defeat + * the point. Otherwise exactly one candidate is required — with several, the + * script asks for a pin instead of picking, because the wrong auth config + * produces a connection that looks fine and grants the wrong scopes. + */ +export function selectAuthConfig( + toolkit: string, + pinned: string | undefined, + candidates: AuthConfigSummary[], +): Decision { + const pin = pinned?.trim(); + if (pin) return { ok: true, value: pin }; + + const matches = candidates.filter( + (candidate) => candidate.toolkit?.slug?.trim().toLowerCase() === toolkit, + ); + + if (matches.length === 0) { + return { + ok: false, + message: + `No auth config exists for "${toolkit}". Add the toolkit at ${DASHBOARD_URL} — ` + + "that is what creates its auth config — then run this again.", + }; + } + + const only = matches[0]; + if (matches.length === 1 && only) return { ok: true, value: only.id }; + + const listed = matches + .map((match) => ` ${match.id}${match.isComposioManaged ? " (Composio-managed)" : ""}`) + .join("\n"); + return { + ok: false, + message: + `"${toolkit}" has ${matches.length} auth configs and none is pinned, so I will not ` + + `guess which one to connect:\n${listed}\n` + + `Pin one with COMPOSIO_AUTH_CONFIGS=${toolkit}: and run this again.`, + }; +} diff --git a/app/tools/composio/connect-tool.tsx b/app/tools/composio/connect-tool.tsx new file mode 100644 index 0000000..cdf3b6f --- /dev/null +++ b/app/tools/composio/connect-tool.tsx @@ -0,0 +1,273 @@ +/** + * `connect_my_app` — posts the connect card and ends the turn. + * + * It does not wait for the OAuth round trip. Slack cuts the live update after + * about a minute (see `userFacingRunError` in app/channel-helpers.ts), and a + * browser authorization routinely exceeds that. + */ +import { z } from "zod"; +import { + defineChannelTool, + type ChannelTool, + type InteractionContext, +} from "@copilotkit/channels"; +import { composioClient } from "./client.js"; +import { readComposioConfig, type ComposioConfig } from "./config.js"; +import { invalidateSession, type Authorization, type ComposioSdk } from "./sessions.js"; +import { ConnectAccount, type ConnectRequest } from "../../human-in-the-loop/index.js"; + +/** + * How long to keep waiting for the browser flow, in ms. + * + * Composio's own default is about a minute, which is shorter than a person + * spends reading a Google consent screen — a naive wait would therefore reject + * on almost every real authorization, right when it was meant to help. + */ +const CONNECT_TIMEOUT_MS = 10 * 60 * 1000; + +/** Whether one person actually received a private message, and how. */ +type PrivateDelivery = { ok: true; usedFallback: boolean } | { ok: false }; + +/** + * Send `text` where only `clicker` can read it, and report whether that worked. + * + * Three ways it does not: `null` when the surface has no ephemeral path and no + * DM fallback was possible, `{ ok: false }` from `channels-core` when the + * adapter implements no `postEphemeral` at all (Teams, today), and a throw when + * the transport fails. All three mean nobody read it, and the caller has to + * know — an ignored result is a click that silently does nothing. + */ +async function postPrivately( + thread: InteractionContext["thread"], + clicker: InteractionContext["actor"], + text: string, +): Promise { + try { + const result = await thread.postEphemeral(clicker, text, { fallbackToDM: true }); + if (!result?.ok) return { ok: false }; + return { ok: true, usedFallback: result.usedFallback === true }; + } catch { + // Swallowed on purpose: a throw here escapes the click handler, the + // dispatcher rethrows it, and the clicker is left with a dead button and no + // explanation. The caller reports the failure instead. Nothing about the + // error is logged — a transport error message can carry the request URL, + // and this function's whole job is handling links nobody else may see. + return { ok: false }; + } +} + +/** + * Drop the clicker's cached sessions again the moment the browser flow + * completes. + * + * Deliberately not awaited: the turn has to end now (Slack cuts the live update + * after about a minute). The invalidation at mint time is the one that runs + * before this returns; this one exists because that one is too early — the user + * is told to ask again, and asking again refills the cache with a session that + * still predates the connection. + * + * The `.catch` is not optional. An abandoned authorization rejects on timeout, + * and Node 22 terminates the process on an unhandled rejection — a user who + * closes the browser tab would take the bot down with them. + */ +function invalidateOnConnection(authorization: Authorization, userId: string): void { + try { + void authorization + .waitForConnection?.(CONNECT_TIMEOUT_MS) + .then(() => invalidateSession(userId)) + .catch(() => {}); + } catch { + // A wait that cannot even start leaves the mint-time invalidation in place, + // which is exactly where this stood before the chain existed. + } +} + +/** + * Tell the clicker, and only the clicker, that the connection could not be + * started — falling back to the thread when there is no private channel, since + * a click that reports nothing is indistinguishable from a broken button. + * + * The provider's own error text is private: it is addressed to one person and + * can quote the request. The public fallback says less on purpose. + * + * `nextStep` is a parameter because the two callers are not the same kind of + * failure. A provider error may well clear on its own; a deployment with no + * `COMPOSIO_API_KEY` never will, and telling that clicker to try again in a + * moment sends them into a loop that cannot end and hides the one fact they + * could act on — that this is the operator's to fix, not theirs. + */ +async function reportStartFailure( + thread: InteractionContext["thread"], + clicker: InteractionContext["actor"], + toolkit: string, + failure: string | undefined, + nextStep = "Try again in a moment.", +): Promise { + const told = await postPrivately( + thread, + clicker, + failure + ? `I could not start the ${toolkit} connection — ${failure}. ${nextStep}` + : `I could not start the ${toolkit} connection. ${nextStep}`, + ); + if (told.ok) return; + await thread.post( + `I could not start the ${toolkit} connection, and I could not message you privately either.`, + ); +} + +/** + * The SDK for this process, re-derived from the environment rather than + * captured. + * + * `composioClient` memoizes, so this is the same instance every other Composio + * path uses; the point is that the click needs nothing carried over from the + * turn that posted the card. `null` means the deployment is no longer + * configured — an operator pulled the key while a card was live. + * + * The `""` default user id is never read here: it only fills + * `config.workspaceUserId`, and connecting always acts as the clicker. + */ +function sdkFromEnv(): ComposioSdk | null { + const config = readComposioConfig(process.env, ""); + return config ? composioClient(config) : null; +} + +/** + * The Connect click. + * + * Lifted out of the JSX for the same reason `handleToolRunDecision` is: this, + * not the tool handler, is where the security decision lives, and a closure + * inside a card is unreachable from a test. + * + * Everything here is derived from `interaction` and the environment — the + * clicker's id, the clicker's thread, and the process-wide SDK. Nothing is + * captured from the turn that posted the card: not the SDK, and above all not + * whoever triggered it, because a link minted for the triggerer and handed to a + * clicker is exactly the account-takeover this whole flow exists to prevent. + * That is also what lets the card be re-rendered from stored props and clicked + * long after a restart. + */ +export async function handleConnectClick( + toolkit: string, + interaction: InteractionContext, +): Promise { + const clicker = interaction.actor; + // An unattributable click cannot be bound to anyone, and guessing would bind + // this account to the wrong person. Nothing is posted: there is no one to + // post it to privately, and the thread is not an option. + if (!clicker?.id) return; + + const thread = interaction.thread; + const sdk = sdkFromEnv(); + if (!sdk) { + await reportStartFailure( + thread, + clicker, + toolkit, + "connected apps are not configured on this deployment (no COMPOSIO_API_KEY)", + "Retrying will not help. Ask whoever runs this deployment to set it.", + ); + return; + } + + let authorization: Authorization | undefined; + let failure: string | undefined; + try { + const session = await sdk.sessions.create(clicker.id, { + toolkits: [toolkit], + // Mandatory. A default session hands the holder remote bash and a Python + // sandbox, which nobody asked for by clicking "Connect Gmail". + workbench: { enable: false }, + }); + authorization = await session.authorize(toolkit); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + // One line per click. The id and the outcome, and deliberately not the link: + // a redirect URL is a bearer capability, so a log line carrying one is a + // credential at rest. + console.log( + `[composio] connect ${toolkit} ` + + `${authorization?.redirectUrl ? "link issued" : "failed"} user=${clicker.id}`, + ); + + if (!authorization?.redirectUrl) { + await reportStartFailure(thread, clicker, toolkit, failure); + return; + } + + // Ephemeral, never `post`. The link binds whoever completes it, so the one + // person who may see it is the one it was minted for. + const delivered = await postPrivately( + thread, + clicker, + `Authorize ${toolkit} here: ${authorization.redirectUrl}`, + ); + + if (!delivered.ok) { + // No private channel, so there is nowhere the link may go. Posting it here + // would let anyone else in the thread open it and bind their own account to + // the clicker's id, which is the one outcome this flow exists to prevent. + await thread.post( + `I could not send you a private message on ${interaction.platform}, and a connect ` + + `link must not be posted where someone else could open it — so I cannot share it ` + + `here. Ask whoever runs this deployment to enable private messages for me.`, + ); + return; + } + + if (delivered.usedFallback) { + await thread.post("📬 I sent you the link as a direct message (only you can open it)."); + } + + // Dropped now rather than on completion, because nothing here waits for the + // round trip. A cached session predating the connect would keep reporting the + // toolkit as unconnected until its TTL expired. + invalidateSession(clicker.id); + // ...and dropped again when the flow actually completes, since the line above + // runs while the user still has the consent screen open. + invalidateOnConnection(authorization, clicker.id); +} + +/** + * No SDK parameter: the card carries no handler to capture one, and the click + * re-derives it from the environment. Only `config` is needed, and only to + * decide which toolkits a person is allowed to connect. + */ +export function createConnectTool(config: ComposioConfig): ChannelTool { + return defineChannelTool({ + name: "connect_my_app", + description: + "Ask the user to connect one of their accounts. Use when search_my_tools reports " + + "a toolkit needs connecting.", + parameters: z.object({ + toolkit: z.string().describe("Toolkit slug, e.g. gmail"), + }), + async handler({ toolkit }, ctx) { + const slug = toolkit.trim().toLowerCase(); + + // Personal toolkits only. A workspace toolkit would connect under the + // clicker's own id while every workspace-scope call resolves under + // `workspaceUserId`, so the account would be created and never used — + // the user authorizes, comes back, and nothing works. + if (config.workspaceToolkits.includes(slug) && !config.userToolkits.includes(slug)) { + return ( + `"${slug}" is a shared team app. Whoever runs this deployment connects it once ` + + `by running \`pnpm composio:connect ${slug}\` on the server; individual users ` + + "do not connect it here." + ); + } + if (!config.userToolkits.includes(slug)) { + return `"${slug}" is not configured on this deployment.`; + } + if (!ctx.actor?.id) { + return "I can't tell who you are on this platform, so I can't connect an account for you."; + } + + await ctx.thread.post(); + + return `Posted a Connect ${slug} card. Stop here — the user connects, then asks again.`; + }, + }); +} diff --git a/app/tools/composio/index.ts b/app/tools/composio/index.ts new file mode 100644 index 0000000..39de626 --- /dev/null +++ b/app/tools/composio/index.ts @@ -0,0 +1,85 @@ +/** + * The Composio feature's single entry point: every tool the agent should see + * for this deployment, or none at all. + * + * Unconfigured means absent, not disabled. Without `COMPOSIO_API_KEY` this + * returns `[]` before anything is constructed — no SDK client, no session + * cache, and no tool the model can see but must not call. + * + * Called once while the Channel is built, so the SDK client and the startup + * warnings happen once per process rather than once per turn. Only `resolve` + * runs per turn, and it is the one thing that depends on who is speaking. + */ +import type { ChannelTool, ChannelToolContext } from "@copilotkit/channels"; +import { composioClient } from "./client.js"; +import { readComposioConfig } from "./config.js"; +import { createConnectTool } from "./connect-tool.js"; +import { createRunTool } from "./run-tool.js"; +import { resolveScopes, startupWarnings } from "./scopes.js"; +import { createSearchTool } from "./search-tool.js"; +import { getSession, type CachedSession } from "./sessions.js"; + +export function composioTools( + env: NodeJS.ProcessEnv, + defaultUserId: string, +): ChannelTool[] { + const config = readComposioConfig(env, defaultUserId); + if (!config) return []; + + for (const warning of startupWarnings(config, env)) console.warn(warning); + + const sdk = composioClient(config); + + /** + * The identities this turn may act as. Resolved per turn because the personal + * scope depends on the actor, and cached per identity by `getSession`, so a + * warm turn costs nothing. + */ + const resolve = async (ctx: ChannelToolContext): Promise => { + const scopes = resolveScopes(config, ctx.actor); + + // `allSettled`, not `all`. A rejected `sessions.create` under `all` loses + // every other scope with it — one unreachable personal account would take + // the team's Linear down for that turn — and rejects `resolve` itself, so + // the turn runs with no Composio tools and no explanation anywhere. + // `search-tool.ts` already draws this line one layer down. + const settled = await Promise.allSettled( + scopes.map((scope) => getSession(sdk, scope.userId, scope.toolkits)), + ); + + const sessions: CachedSession[] = []; + settled.forEach((outcome, index) => { + if (outcome.status === "fulfilled") { + sessions.push(outcome.value); + return; + } + // The scope, so an operator can tell whose account went missing, and the + // provider's reason, so they can tell why. Neither carries a credential: + // the api key never leaves `client.ts`, and unlike a connect link a + // `sessions.create` failure is not itself a bearer capability. + const scope = scopes[index]!; + const reason = + outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); + console.warn( + `[composio] no session for user=${scope.userId} ` + + `toolkits=${scope.toolkits.join(",")} — running the turn without it: ${reason}`, + ); + }); + + return sessions; + }; + + const tools = [ + createSearchTool(resolve), + createRunTool(resolve, config.approvals, config.workspaceUserId), + ]; + + // Connecting is a personal act. With no personal toolkits there is nothing a + // user could connect, and offering the tool would only invite the agent to + // tell someone to connect a shared account it does not own. + if (config.userToolkits.length > 0) { + tools.push(createConnectTool(config)); + } + + return tools; +} diff --git a/app/tools/composio/pending.ts b/app/tools/composio/pending.ts new file mode 100644 index 0000000..f7fb1b3 --- /dev/null +++ b/app/tools/composio/pending.ts @@ -0,0 +1,89 @@ +/** + * Calls awaiting approval. + * + * The card carries only a token. Arguments stay here rather than in the Slack + * action value, which has a size limit and should not carry user data. A token + * missing after a restart is reported as expired rather than guessed at. + */ +import { randomUUID } from "node:crypto"; +import type { Effect } from "./classify.js"; +import type { RawSession } from "./sessions.js"; + +export interface PendingCall { + session: RawSession; + slug: string; + args: Record; + effect: Effect; + /** The identity this call was composed for. */ + userId: string; + workspaceUserId: string; + /** Human-readable label already computed for the card. */ + action: string; +} + +/** + * How many approvals may be outstanding at once. Matching + * `agent/write_confirmation.py`'s `_MAX_TRACKED_FAILURES`, and bounded for the + * same reason: this map outlives every conversation, and each entry pins a live + * `RawSession` plus the call's arguments — which is where the mail bodies are. + * An abandoned card is not worth holding either of those forever. + */ +const MAX_PENDING = 64; + +/** Insertion-ordered by construction, which is what makes eviction oldest-first. */ +const pending = new Map(); + +export function clearPending(): void { + pending.clear(); +} + +/** + * A token is a capability: for a workspace-scope call, holding it is the whole + * of the authorization. So it comes from the CSPRNG — `Math.random()` is + * seeded, unseeded-predictable and worth about 41 bits, none of which is a + * budget to defend a delete with. + */ +export function registerPending(call: PendingCall): string { + const token = `ctr_${randomUUID()}`; + pending.set(token, call); + // Oldest-first, and after the insert so a fresh call is never the casualty. + while (pending.size > MAX_PENDING) { + const oldest = pending.keys().next(); + if (oldest.done) break; + pending.delete(oldest.value); + } + return token; +} + +/** Read and consume. A card cannot be clicked twice into two executions. */ +export function takePending(token: string): PendingCall | undefined { + const call = pending.get(token); + if (call) pending.delete(token); + return call; +} + +/** + * Undo one `takePending`, under the token the posted card already carries. + * + * Only for a read that turned out not to be a decision the reader was allowed + * to make. `registerPending` would mint a fresh token and strand the call: the + * card in the thread still holds the original, so the rightful approver's click + * would read "expired" and the delete they asked for could never run. + * + * Safe against replay because the caller restores before it awaits anything — + * no second click can be dispatched into the window between the take and this. + */ +export function restorePending(token: string, call: PendingCall): void { + pending.set(token, call); +} + +/** + * A workspace-scope call runs against a shared identity, so anyone in the thread + * may approve it. A personal-scope call may only be approved by the person it + * was composed for — otherwise approving someone else's delete would execute + * against the approver's own account. + */ +export function mayApprove(call: PendingCall, clickerId: string | undefined): boolean { + if (call.userId === call.workspaceUserId) return true; + return Boolean(clickerId) && clickerId === call.userId; +} diff --git a/app/tools/composio/run-tool.tsx b/app/tools/composio/run-tool.tsx new file mode 100644 index 0000000..0a45d3b --- /dev/null +++ b/app/tools/composio/run-tool.tsx @@ -0,0 +1,244 @@ +/** + * `run_my_tool` — the execution half of the router, and the approval gate. + * + * Composio's hosted executor would send the call from the model straight to + * Composio, bypassing this file and therefore bypassing approval. That is the + * whole reason this wrapper exists. + * + * Managed Channels cannot block on a choice, so a gated call posts a card and + * ends the turn; the button handler executes and rewrites the card. The model + * therefore never sees a gated call's result — acceptable while `destructive` is + * the default, since the gated case is deletes. + */ +import { z } from "zod"; +import { + defineChannelTool, + type ChannelTool, + type InteractionContext, +} from "@copilotkit/channels"; +import type { ApprovalMode } from "./config.js"; +import { needsApproval, type Effect } from "./classify.js"; +import type { ScopeResolver } from "./search-tool.js"; +import type { CachedSession } from "./sessions.js"; +import { mayApprove, registerPending, restorePending, takePending } from "./pending.js"; +import { + ConfirmToolRun, + ToolRunOutcome, + toolRunFields, + type ConfirmDecision, +} from "../../human-in-the-loop/index.js"; + +/** + * What a slug the effect map has never heard of is treated as. + * + * Not `"write"`. The map is filled from `getRawComposioTools({ limit: 300 })`, + * so a real slug past that cap is unmapped through no fault of the model — and + * a hallucinated or prompt-injected slug is unmapped too. `needsApproval( + * "write", "destructive")` is `false`, so calling any of those a write would + * run them unapproved in the mode that ships by default. Gated everywhere but + * `off` is the only reading of "unrecognised" that is actually fail-safe. + * + * A slug that IS classified and merely isn't read-only stays a write. + */ +const UNMAPPED_EFFECT: Effect = "destructive"; + +/** + * Does this scope own the toolkit the slug belongs to? + * + * Composio slugs are `TOOLKIT_REST_OF_NAME` with the toolkit uppercased — + * `GMAIL_SEND_EMAIL`, `GOOGLECALENDAR_EVENTS_LIST` — so the prefix is the only + * thing needed to place a slug the effect map never saw. Which is the case that + * matters: the map is filled with `limit: 300`, and a real slug past that cap + * would otherwise fall to `scopes[0]`, the shared account, which does not carry + * the toolkit at all. + */ +function ownsSlug(scope: CachedSession, slug: string): boolean { + const upper = slug.toUpperCase(); + return scope.toolkits.some((toolkit) => upper.startsWith(`${toolkit.toUpperCase()}_`)); +} + +/** `GMAIL_SEND_EMAIL` -> `Gmail send email`. */ +function actionLabel(slug: string): string { + const words = slug.toLowerCase().split("_").filter(Boolean); + if (words.length === 0) return slug; + return [words[0]![0]!.toUpperCase() + words[0]!.slice(1), ...words.slice(1)].join(" "); +} + +/** + * One line per Composio call. Slug, effect, resolved identity and Composio's own + * `logId` — enough to correlate with their dashboard, and deliberately nothing + * from `args`, which is where the credentials and the mail bodies are. + */ +function logLine( + slug: string, + effect: Effect, + userId: string, + logId: string | undefined, + ok: boolean, +) { + console.log( + `[composio] ${slug} ${ok ? "ok" : "failed"} effect=${effect} user=${userId} log=${logId ?? "none"}`, + ); +} + +/** + * The Approve/Cancel click on a `ConfirmToolRun` card. + * + * Lifted out of the JSX because this, not the tool handler, is where the + * security decisions live: an expired token, the wrong person clicking, and a + * provider failure that arrives as a resolved value rather than a throw. A + * closure inside the card is unreachable from a test; an exported function is + * not. + * + * `cardAction` is the label the card was posted with. It is used only when no + * pending call can be recovered — every other branch reports `call.action`, the + * label of the call actually being decided. + */ +export async function handleToolRunDecision( + interaction: InteractionContext, + cardAction: string, +): Promise { + const decision = interaction.action.value; + // No value at all is not this card's button; there is nothing to report. + if (!decision) return; + + // An empty token names no call, so it gets the same answer as a token whose + // call is gone. Returning silently would leave a live card and no feedback. + const call = decision.token ? takePending(decision.token) : undefined; + if (!call) { + await interaction.thread.update( + interaction.message.ref, + , + ); + return; + } + + if (!mayApprove(call, interaction.actor?.id)) { + // The wrong person clicked; the request still stands. Restored under the + // same token, before any await, so the live card keeps working and no + // second click can slip into the gap. The card is not rewritten either — + // that would strip the buttons the rightful approver needs. + restorePending(decision.token, call); + await interaction.thread.post(`Only the person who asked can approve **${call.action}**.`); + return; + } + + // Anything other than an explicit approval is a decline. The token is already + // consumed, so a declined card cannot be clicked into a run. + if (decision.approved !== true) { + await interaction.thread.update( + interaction.message.ref, + , + ); + return; + } + + // Two ways this fails. `execute` resolves with an `error` when the tool + // itself rejects the call, and throws when the transport does — a timeout, a + // dead session. The token is already consumed either way, so a throw that + // escaped here would leave the card frozen with no result and no way back. + let logId: string | undefined; + let failure: string | undefined; + try { + const result = await call.session.execute(call.slug, call.args); + logId = result?.logId; + failure = result?.error ?? undefined; + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + logLine(call.slug, call.effect, call.userId, logId, !failure); + + await interaction.thread.update( + interaction.message.ref, + , + ); +} + +export function createRunTool( + resolve: ScopeResolver, + mode: ApprovalMode, + workspaceUserId: string, +): ChannelTool { + return defineChannelTool({ + name: "run_my_tool", + description: + "Run one action found with search_my_tools. Pass the exact slug and arguments " + + "matching that tool's input schema.", + parameters: z.object({ + slug: z.string().describe("Exact tool slug, e.g. GMAIL_SEND_EMAIL"), + args: z.record(z.unknown()).describe("Arguments matching the tool's input schema"), + }), + async handler({ slug, args }, ctx) { + const scopes = await resolve(ctx); + if (scopes.length === 0) return "Connected apps are not configured for you."; + + // Route to the scope that knows this slug, then to the scope that owns + // its toolkit, and only then to the first. The middle step is not a + // nicety: scopes arrive workspace-first, so without it an unmapped + // personal slug runs against the shared session — which cannot execute it + // — and its approval card binds to `workspaceUserId`, making one person's + // action approvable by anyone in the thread. The final fallback stands so + // a slug belonging to nothing still produces a real provider error rather + // than silence. + const scope = + scopes.find((s) => s.effects.has(slug)) ?? + scopes.find((s) => ownsSlug(s, slug)) ?? + scopes[0]!; + const effect: Effect = scope.effects.get(slug) ?? UNMAPPED_EFFECT; + const action = actionLabel(slug); + // The identity of the scope the slug routed to, not the actor's. They + // differ whenever a person's message runs through the shared workspace + // account, and it is the account being touched that decides who may + // approve — a shared one belongs to the thread, a personal one to its + // owner. + const userId = scope.userId; + + if (needsApproval(effect, mode)) { + const token = registerPending({ + session: scope.session, + slug, + args, + effect, + userId, + workspaceUserId, + action, + }); + + await ctx.thread.post( + , + ); + + return ( + `Posted an approval card for ${action}. Stop here — do not retry or ` + + "explain. The result is reported on the card." + ); + } + + const result = await scope.session.execute(slug, args); + + // execute() does NOT throw on tool failure. A try/catch alone would treat + // every failed write as a success. + if (result?.error) { + logLine(slug, effect, userId, result.logId, false); + return `Tool ${slug} failed: ${result.error}`; + } + + logLine(slug, effect, userId, result?.logId, true); + return result?.data ?? null; + }, + }); +} diff --git a/app/tools/composio/scopes.ts b/app/tools/composio/scopes.ts new file mode 100644 index 0000000..93da6df --- /dev/null +++ b/app/tools/composio/scopes.ts @@ -0,0 +1,93 @@ +/** + * Which Composio identities a turn acts as, and the misconfigurations worth + * saying out loud at startup. + */ +import type { ComposioConfig } from "./config.js"; + +export interface ResolvedScope { + userId: string; + toolkits: string[]; +} + +/** Apps whose data is one person's, not a team's. */ +const PERSONAL_TOOLKITS = new Set(["gmail", "googlecalendar", "outlook", "googledrive"]); + +/** Composio toolkit slug -> the env var enabling the same app over MCP. */ +const MCP_EQUIVALENTS: Record = { + linear: "LINEAR_API_KEY", + notion: "NOTION_MCP_AUTH_TOKEN", + posthog: "POSTHOG_PERSONAL_API_KEY", + github: "GITHUB_PERSONAL_ACCESS_TOKEN", +}; + +/** + * Every applicable scope, not the first match. + * + * A toolkit named in both lists resolves to the personal scope only: routing by + * slug is ambiguous when a slug lives in two sessions, and picking whichever + * loaded first would attribute an issue to a person or to a shared account + * depending on restart order. + * + * That de-duplication is unconditional — it does not depend on the personal + * scope actually resolving. Naming a toolkit in `userToolkits` is the operator + * saying it must run as the person, so an unidentified turn gets no access to + * it rather than falling through to the shared account. + */ +export function resolveScopes( + config: ComposioConfig, + actor: { id?: string } | undefined, +): ResolvedScope[] { + const scopes: ResolvedScope[] = []; + + /** + * The single place a personal identity is admitted. Blank is not an identity: + * `{ id: "" }` and `{}` are as unverified as no actor at all, and matching + * `readEnvironment`'s `?.trim() ||` idiom keeps a whitespace-only id out too. + */ + const actorId = actor?.id?.trim() || undefined; + + const workspaceToolkits = config.workspaceToolkits.filter( + (slug) => !config.userToolkits.includes(slug), + ); + + if (workspaceToolkits.length > 0) { + scopes.push({ userId: config.workspaceUserId, toolkits: workspaceToolkits }); + } + if (actorId !== undefined && config.userToolkits.length > 0) { + scopes.push({ userId: actorId, toolkits: config.userToolkits }); + } + return scopes; +} + +export function startupWarnings(config: ComposioConfig, env: NodeJS.ProcessEnv): string[] { + const warnings: string[] = []; + + for (const slug of new Set(config.workspaceToolkits)) { + if (config.userToolkits.includes(slug)) { + warnings.push( + `[composio] "${slug}" is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS. ` + + "Using each person's own account; the shared one is ignored for this app.", + ); + continue; + } + if (PERSONAL_TOOLKITS.has(slug)) { + warnings.push( + `[composio] "${slug}" is in COMPOSIO_TOOLKITS (shared). Every Slack user will act ` + + "through ONE account. If you meant each person to use their own, move it to " + + "COMPOSIO_USER_TOOLKITS.", + ); + } + } + + for (const slug of new Set([...config.workspaceToolkits, ...config.userToolkits])) { + const mcpVar = MCP_EQUIVALENTS[slug]; + if (!mcpVar || !env[mcpVar]?.trim()) continue; + warnings.push( + `[composio] "${slug}" is configured twice: via Composio and via ${mcpVar}. ` + + "The agent will see two sets of tools for it and may pick either, so whether " + + "an action asks for approval will vary. Remove one to make this predictable.", + ); + } + + return warnings; +} diff --git a/app/tools/composio/search-tool.ts b/app/tools/composio/search-tool.ts new file mode 100644 index 0000000..5fa9ad7 --- /dev/null +++ b/app/tools/composio/search-tool.ts @@ -0,0 +1,161 @@ +/** + * `search_my_tools` — the discovery half of the router. + * + * Binding every tool of every connected toolkit is not viable: gmail alone is + * 63 tools, linear 47, googlecalendar 49. So the model searches instead, and + * gets back a handful of candidates. + * + * Composio's search returns schemas in the same response, so this collapses + * their search -> get_schemas -> execute into search -> execute, and the model + * fills a real JSON schema rather than reconstructing one from prose. + */ +import { z } from "zod"; +import { defineChannelTool, type ChannelTool, type ChannelToolContext } from "@copilotkit/channels"; +import type { CachedSession } from "./sessions.js"; + +/** How many candidates the model sees. Tunable; not a principle. */ +const MAX_RESULTS = 5; + +/** + * Which Composio sessions this turn may act through. An array, because one turn + * can be both the shared team identity and the person who sent the message. + */ +export type ScopeResolver = (ctx: ChannelToolContext) => Promise; + +/** One candidate handed to the model. */ +interface Candidate { + slug: string; + description: string; + /** `null` when the response carried no schema — the model cannot call it. */ + inputSchema: unknown; +} + +/** + * The parts of the search response we read. Every field is optional in + * practice, and the response itself may not be an object at all, so nothing + * here is assumed present or well-typed. + */ +interface SearchResponse { + results?: unknown; + toolSchemas?: unknown; + toolkitConnectionStatuses?: unknown; +} + +interface ToolSchema { + description?: unknown; + inputSchema?: unknown; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asStrings(value: unknown): string[] { + return asArray(value).filter((item): item is string => typeof item === "string"); +} + +/** Every candidate one scope offers, in the order that scope ranked them. */ +function candidatesOf(response: SearchResponse): Candidate[] { + const schemas = asRecord(response.toolSchemas); + const candidates: Candidate[] = []; + + for (const entry of asArray(response.results)) { + const result = asRecord(entry); + // Primary slugs before related ones: that is the scope's own ranking. + const slugs = [...asStrings(result.primaryToolSlugs), ...asStrings(result.relatedToolSlugs)]; + for (const slug of slugs) { + const schema = asRecord(schemas[slug]) as ToolSchema; + candidates.push({ + slug, + description: typeof schema.description === "string" ? schema.description : "", + inputSchema: schema.inputSchema ?? null, + }); + } + } + + return candidates; +} + +/** + * Round-robin across the scopes rather than concatenating them. + * + * Scopes arrive workspace-first, and the cap is global. Concatenating would let + * a chatty shared scope fill all five slots and make the requesting person's own + * apps unreachable — "what's on my calendar" answering with only Linear tools. + * Taking one candidate from each scope in turn keeps every scope represented. + * + * Deduplicated by slug, first occurrence wins. Linear scan on purpose: n is a + * handful, and a Set would buy nothing here. + */ +function interleave(perScope: Candidate[][]): Candidate[] { + const merged: Candidate[] = []; + const deepest = perScope.reduce((max, list) => Math.max(max, list.length), 0); + + for (let rank = 0; rank < deepest; rank++) { + for (const list of perScope) { + const candidate = list[rank]; + if (!candidate) continue; + if (merged.some((t) => t.slug === candidate.slug)) continue; + merged.push(candidate); + } + } + + return merged; +} + +export function createSearchTool(resolve: ScopeResolver): ChannelTool { + return defineChannelTool({ + name: "search_my_tools", + description: + "Find actions available in the connected apps. Call this before run_my_tool. " + + "Returns tool slugs with their input schemas.", + parameters: z.object({ + query: z.string().describe("What you want to do, in plain words, e.g. 'send an email'"), + }), + async handler({ query }, ctx) { + const scopes = await resolve(ctx); + if (scopes.length === 0) return "Connected apps are not configured for you."; + + // One round trip per scope, in parallel. `allSettled` so a scope whose + // session is broken costs only its own candidates, not everyone else's. + const settled = await Promise.allSettled( + scopes.map((scope) => scope.session.search({ query })), + ); + + const perScope: Candidate[][] = []; + const needsConnection: string[] = []; + + for (const outcome of settled) { + if (outcome.status !== "fulfilled") continue; + const response = asRecord(outcome.value) as SearchResponse; + + perScope.push(candidatesOf(response)); + + // Only an explicit `false` means "not connected" — an absent status is + // silence, not something to prompt the user about. + for (const entry of asArray(response.toolkitConnectionStatuses)) { + const status = asRecord(entry); + if (status.hasActiveConnection !== false) continue; + if (typeof status.toolkit !== "string") continue; + if (!needsConnection.includes(status.toolkit)) needsConnection.push(status.toolkit); + } + } + + // A schema-less candidate is uncallable, so it must never displace a + // callable one — but it still ships, so the model can see the tool exists. + const merged = interleave(perScope); + const tools = [ + ...merged.filter((t) => t.inputSchema !== null), + ...merged.filter((t) => t.inputSchema === null), + ]; + + return { tools: tools.slice(0, MAX_RESULTS), needsConnection }; + }, + }); +} diff --git a/app/tools/composio/sessions.ts b/app/tools/composio/sessions.ts new file mode 100644 index 0000000..7f47c41 --- /dev/null +++ b/app/tools/composio/sessions.ts @@ -0,0 +1,114 @@ +/** + * Per-user Composio sessions, cached in process. + * + * Composio stores connected accounts server-side keyed by user id, so a restart + * empties this cache but never requires anyone to re-authenticate. The first + * message from each person after a restart pays one cache miss (~1s measured). + */ +import { effectOf, type Effect } from "./classify.js"; + +/** + * What `session.authorize(toolkit)` hands back. `id` and `status` are carried + * for completeness; the two fields anything reads are the link and the wait. + */ +export interface Authorization { + /** The connect link. A bearer capability — see `connect-tool.tsx`. */ + redirectUrl: string; + id?: string; + status?: string; + /** + * Resolves once the person finishes the browser flow. Optional because a + * session from an older SDK may not offer it, and a caller that only wants + * the link must not depend on it. + * + * Composio's own default wait is roughly a minute — shorter than a person + * spends on a consent screen — so every caller passes its own timeout in ms. + */ + waitForConnection?(timeoutMs?: number): Promise; +} + +/** Structural subset of `@composio/core` we depend on, so tests can fake it. */ +export interface RawSession { + sessionId: string; + search(params: { query: string }): Promise; + execute( + slug: string, + args: Record, + ): Promise<{ data?: unknown; error?: string | null; logId?: string }>; + authorize(toolkit: string): Promise; + toolkits(): Promise<{ + items: Array<{ slug: string; connection?: { isActive?: boolean } }>; + }>; +} + +export interface ComposioSdk { + sessions: { + create(userId: string, options: Record): Promise; + }; + tools: { + getRawComposioTools(params: { + toolkits: string[]; + limit: number; + }): Promise>; + }; +} + +export interface CachedSession { + session: RawSession; + /** + * The Composio identity this session acts as — the shared workspace id, or + * one person's. Carried on the entry because a caller that routed a slug to + * this scope cannot otherwise tell whose account it is about to touch, and + * approval depends on exactly that. + */ + userId: string; + /** tool slug -> effect, derived once per cache fill. */ + effects: Map; + toolkits: string[]; + filledAt: number; +} + +/** Long enough that the hot path is free, short enough that a new app appears. */ +const TTL_MS = 10 * 60 * 1000; + +const cache = new Map(); + +/** One user can hold both a workspace and a personal session, so key on both. */ +function cacheKey(userId: string, toolkits: string[]): string { + return `${userId}::${[...toolkits].sort().join(",")}`; +} + +export function clearSessionCache(): void { + cache.clear(); +} + +export function invalidateSession(userId: string): void { + for (const key of [...cache.keys()]) { + if (key.startsWith(`${userId}::`)) cache.delete(key); + } +} + +export async function getSession( + sdk: ComposioSdk, + userId: string, + toolkits: string[], +): Promise { + const key = cacheKey(userId, toolkits); + const existing = cache.get(key); + if (existing && Date.now() - existing.filledAt <= TTL_MS) return existing; + + // workbench:false is mandatory. A default session exposes remote bash and a + // Python sandbox, which nobody asked for by naming a toolkit. + const session = await sdk.sessions.create(userId, { + toolkits, + workbench: { enable: false }, + }); + + const raw = await sdk.tools.getRawComposioTools({ toolkits, limit: 300 }); + const effects = new Map(); + for (const tool of raw) effects.set(tool.slug, effectOf(tool.tags)); + + const entry: CachedSession = { session, userId, effects, toolkits, filledAt: Date.now() }; + cache.set(key, entry); + return entry; +} diff --git a/scripts/composio-connect.ts b/scripts/composio-connect.ts new file mode 100644 index 0000000..0d3c73c --- /dev/null +++ b/scripts/composio-connect.ts @@ -0,0 +1,141 @@ +/** + * Connect a shared team toolkit — the operator-side half of the Composio + * integration. + * + * Composio scopes every connected account to a `user_id`, and shared-scope + * calls resolve under `COMPOSIO_WORKSPACE_USER_ID` (defaulting to + * `INTELLIGENCE_CHANNEL_NAME`). The dashboard's "connect my account" button + * binds to the dashboard's own user id, which OpenTag never passes, so an + * account created that way is invisible to the bot forever. This script mints a + * Connect Link against the workspace identity instead, which is the only way a + * shared toolkit becomes usable. + * + * Personal toolkits are not connected here: those go through the Connect card + * in a thread, minted per person. + * + * Runs standalone — no Slack runtime, no agent, no server. + * + * pnpm composio:connect linear + */ +import "dotenv/config"; +import { Composio } from "@composio/core"; +import { DEFAULT_INTELLIGENCE_CHANNEL_NAME } from "../app/env.js"; +import { readComposioConfig } from "../app/tools/composio/config.js"; +import { + resolveSharedToolkit, + selectAuthConfig, + type AuthConfigSummary, +} from "../app/tools/composio/connect-link.js"; + +/** Every exit from here is a failure with a fix in it. */ +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +/** + * Walk `authConfigs.list()` to the end. + * + * The listing is paginated and a project with many toolkits overflows one page, + * so stopping at page one would report "no auth config" for a toolkit that has + * one — the single most misleading thing this script could say. + */ +async function listAuthConfigs( + composio: Composio, + toolkit: string, +): Promise { + const collected: AuthConfigSummary[] = []; + let cursor: string | undefined; + + do { + const page = await composio.authConfigs.list({ toolkit, ...(cursor ? { cursor } : {}) }); + collected.push(...page.items); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + return collected; +} + +async function main(): Promise { + // Same reader the runtime uses, so the defaults and validation an operator is + // debugging are the ones that actually apply at boot. + const config = readComposioConfig( + process.env, + process.env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, + ); + if (!config) { + fail( + "Composio is not configured. Set COMPOSIO_API_KEY and at least one of " + + "COMPOSIO_TOOLKITS / COMPOSIO_USER_TOOLKITS in your .env, then run this again.", + ); + } + + const toolkit = resolveSharedToolkit(config, process.argv[2]); + if (!toolkit.ok) fail(toolkit.message); + const slug = toolkit.value; + + const composio = new Composio({ + apiKey: config.apiKey, + allowTracking: false, + disableVersionCheck: true, + }); + + let candidates: AuthConfigSummary[]; + try { + candidates = await listAuthConfigs(composio, slug); + } catch (error) { + // The message may name the endpoint but never the key — the SDK does not + // echo it, and nothing here adds it. + fail( + `Could not list auth configs for "${slug}": ${describe(error)}\n` + + "Check COMPOSIO_API_KEY is valid for the project you expect.", + ); + } + + const authConfig = selectAuthConfig(slug, config.authConfigs[slug], candidates); + if (!authConfig.ok) fail(authConfig.message); + + let redirectUrl: string | undefined; + try { + const request = await composio.connectedAccounts.link(config.workspaceUserId, authConfig.value); + redirectUrl = request.redirectUrl ?? undefined; + } catch (error) { + fail(`Could not create a Connect Link for "${slug}": ${describe(error)}`); + } + + if (!redirectUrl) { + fail( + `Composio returned no redirect URL for "${slug}" (auth config ${authConfig.value}). ` + + `That auth config may not use a browser flow — check it at https://app.composio.dev.`, + ); + } + + // Printing the link is this script's whole purpose and it goes to the + // operator's own terminal, but it is a bearer capability: whoever opens it + // binds *their* account to the shared identity below. + console.log( + [ + `Connect ${slug} for the shared workspace identity.`, + ``, + ` toolkit: ${slug}`, + ` auth config: ${authConfig.value}`, + ` binds to: ${config.workspaceUserId} (COMPOSIO_WORKSPACE_USER_ID)`, + ``, + `Open this link yourself, in a browser signed in to the account the whole team`, + `should act through. Do not forward it — whoever completes it is the account`, + `every shared ${slug} call will run as.`, + ``, + redirectUrl, + ``, + `Then restart the runtime and ask the agent for ${slug}.`, + ].join("\n"), + ); +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +main().catch((error: unknown) => { + fail(describe(error)); +}); From 706ba4b111f7d994f48210b740302a90a7410254 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 21 Aug 2026 20:46:51 +0200 Subject: [PATCH 3/4] docs(composio): document setup, approvals, and the connect flow Covers the three setup steps per app, the extra terminal step a shared toolkit needs, and the frictions a self-hoster meets: Google's consent screen names Composio rather than OpenTag; self-branding without Google verification is worse, not better, because users get an unverified-app warning; all of a person's Composio connections share one Google grant, so revoking disconnects every app at once; and a Workspace admin can block the flow before it reaches OpenTag. Also documents that the approval card renders argument values into the channel, so approving an email puts its body in front of the thread. That is deliberate -- an approver who cannot see what they are approving cannot approve it -- but an operator should weigh it before enabling personal toolkits. --- .env.example | 31 +++++++ README.md | 19 +++- setup.md | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 288 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index a93ba82..605e19d 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,34 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Notion (Optional) -- # export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token + +# -- Composio (Optional) -- +# Connect any Composio toolkit without writing an MCP block. Two steps per app: +# add the toolkit at https://app.composio.dev (that creates its auth config), +# then list its slug below and restart the runtime. Read by the Node runtime, +# not the Python agent. Requires Node 22+ (package.json `engines`). +# Slugs are Composio's own, lowercase and unspaced — Google Calendar is +# `googlecalendar`. Take it from the toolkit's page URL at app.composio.dev. A +# typo is silent: the toolkit is configured and simply never appears. +# export COMPOSIO_API_KEY=ak_... +# Shared team accounts — every Slack user acts through the SAME connection. +# After setting this, connect each one once: `pnpm composio:connect linear`, +# then open the link it prints. Nobody can do it from Slack, and the dashboard +# binds the wrong identity (see COMPOSIO_WORKSPACE_USER_ID below). +# export COMPOSIO_TOOLKITS=linear,jira +# Personal accounts — each person connects their own from inside a thread. +# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar +# off | destructive (default) | writes +# export COMPOSIO_APPROVALS=destructive +# The Composio user_id shared toolkits run as. Defaults to +# INTELLIGENCE_CHANNEL_NAME; a shared toolkit is only connected when a connected +# account exists under this exact value. The dashboard's "connect my account" +# button binds to the dashboard's own user id and the bot never sees it — +# `pnpm composio:connect` binds to this value instead, which is why it exists. +# export COMPOSIO_WORKSPACE_USER_ID= +# Rarely needed, and case-sensitive. Read ONLY by `pnpm composio:connect`, not +# by the runtime: it pins which auth config a SHARED toolkit connects against +# when it has more than one (the script prints the ids to choose from and will +# not guess). Personal toolkits cannot be pinned — session.authorize() takes no +# auth config id and resolves one itself. +# export COMPOSIO_AUTH_CONFIGS=gmail:ac_ExAmPle2Cd,linear:ac_ExAmPle1-aB diff --git a/README.md b/README.md index b9485b4..8afe1cb 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ AGENT_DISPLAY_NAME=OpenTag Both the Node runtime and the Python agent load this one root `.env`; Railway supplies the same values as service variables. Tavily, GitHub, PostHog, Linear, -and Notion are optional — see [Optional research +Notion, and Composio are optional — see [Optional research sources](#optional-research-sources). `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` already default to the @@ -279,7 +279,8 @@ or one directory, and none of them require touching the Channel lifecycle. | Which tools the agent has | [`agent/tools.py`](./agent/tools.py), [`agent/internal_sources.py`](./agent/internal_sources.py) | Sources register only when their credentials are present | | What gets rendered in chat | [`app/components/`](./app/components), [`app/tools/`](./app/tools) | Issue cards, tables, charts, diagrams | | Mentions, commands, triggers | [`app/channel.tsx`](./app/channel.tsx) | The whole Channel surface in one file | -| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write` | +| Which writes need approval | [`agent/write_confirmation.py`](./agent/write_confirmation.py) | The interceptor that emits `confirm_write`; `COMPOSIO_APPROVALS` is the equivalent dial for Composio | +| Which connected apps people can use | `COMPOSIO_TOOLKITS`, `COMPOSIO_USER_TOOLKITS` | One slug per app. Adding one later needs no code change — but it does need a dashboard step and a restart | | The deployment topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services, declared as code | If you are customizing with a coding agent, read [`AGENTS.md`](./AGENTS.md) @@ -295,6 +296,8 @@ workflow instead of letting an agent improvise one. - File-aware prompts. - A LangGraph interrupt and resumable confirmation card before Linear or Notion writes. +- Optional Composio toolkits resolved per turn — a shared team account, each + person's own, or both at once — behind their own approval gate. - Graceful, idempotent shutdown for Channels, HTTP, and the rendering browser. - Nullable parent-message ID normalization through `SanitizingHttpAgent`. @@ -312,6 +315,8 @@ CopilotKit Intelligence │ outbound websocket from your runtime ▼ runtime (Node + CopilotRuntime with embedded Channels) + ├── Composio toolkits (optional; shared or per-Slack-user) + │ │ AG-UI ▼ agent (Python + LangGraph deepagents) @@ -363,6 +368,16 @@ knowledge work, and renders UI from model knowledge. | `LINEAR_API_KEY` | Hosted Linear MCP | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | +| `COMPOSIO_API_KEY` + `COMPOSIO_TOOLKITS` and/or `COMPOSIO_USER_TOOLKITS` | Any Composio toolkit — shared team accounts, or each person's own connected from inside a thread | + +Composio is the one that grows without code. Adding Salesforce six months from +now is two steps: add the toolkit at [app.composio.dev](https://app.composio.dev), +then add its slug to a list and restart the runtime. No new MCP block, no +TypeScript, no test change. Two steps, though — neither of them automatic, and a +shared team account takes a third: `pnpm composio:connect ` once, to bind +the connection to the identity shared calls actually run as. See +[`setup.md`](./setup.md#composio) for approval modes, shared versus personal +accounts, and what Google's consent screen will tell your users. Every Linear and Notion mutation is intercepted in code before the MCP request runs. The interceptor emits `confirm_write` and proceeds only after approval; diff --git a/setup.md b/setup.md index 7826442..66967d8 100644 --- a/setup.md +++ b/setup.md @@ -47,6 +47,11 @@ uv sync cd .. ``` +Node 22 is a floor, not a preference. `package.json` declares +`engines: { node: ">=22" }` because `@composio/core` pulls `openai@7`, which +requires it. On Node 20 you are stopped at install rather than at runtime, which +is the point. + `@copilotkit/channels` and `@copilotkit/runtime` are intentionally pinned. [`package.json`](./package.json) is the single source of truth for both versions; this file does not restate them, because a hand-copied pin drifts on @@ -146,6 +151,12 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `PORT` | No | Channel HTTP port; defaults to `3000` | | `LOG_LEVEL` | No | Defaults to `error`; use `debug` to see Channel lifecycle breadcrumbs | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | +| `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | +| `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | +| `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message | +| `COMPOSIO_APPROVALS` | No | `off`, `destructive` (default), or `writes`. An unrecognized value fails startup | +| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as; defaults to `INTELLIGENCE_CHANNEL_NAME` | +| `COMPOSIO_AUTH_CONFIGS` | No | **Read only by `pnpm composio:connect`, never by the runtime.** `toolkit:auth_config_id` pairs, ids case-sensitive; pins which auth config a *shared* toolkit connects against when it has several | The API key selects a project; the Channel name selects a Channel inside it. When `INTELLIGENCE_LEARNING_CONTAINER_ID` is set, it must name an existing @@ -279,6 +290,9 @@ Before a Linear or Notion mutation reaches MCP, a Python interceptor emits graph with the user's decision. The MCP handler runs only after approval. Reads and UI rendering are never gated. +Composio calls are gated separately and differently — a second card, its own +`COMPOSIO_APPROVALS` dial, and no graph to resume. See [Composio](#composio). + ## Optional sources ### Tavily @@ -333,6 +347,230 @@ Notion is optional and remote-only, not a separate Railway service. Set both discovers the tools. If either value is absent OpenTag skips Notion without blocking startup. +### Composio + +Composio adds a toolkit — Gmail, Linear, Jira, Google Calendar, Salesforce — +without a new MCP block, a `preserve()` line, or a matching test assertion. It +lives in the Node runtime, not the Python agent, because a verified user +identity exists only there: `ChannelToolContext` carries a resolved actor, and +`Thread.runAgent()` has no way to hand a structured user id to the agent. + +Setup is **two steps per app**, not one: + +1. Add the toolkit at . That creates its auth config. +2. Add its slug to `COMPOSIO_TOOLKITS` or `COMPOSIO_USER_TOOLKITS`. For a + **shared** toolkit, also run `pnpm composio:connect ` once and open the + link it prints — that needs no running runtime, so do it before you restart. + Nobody in Slack can do it for you, and the dashboard cannot either; see + [Shared team accounts versus personal + ones](#shared-team-accounts-versus-personal-ones). Personal toolkits skip it + — each user connects their own from a thread. +3. Restart the runtime, once. + +**The slug is the tricky part.** It is Composio's own, lowercase and unspaced: +Google Calendar is `googlecalendar`, not `google-calendar` or `gcal`. Take it +from the toolkit's page URL at (`/toolkit/gmail`), or +from the Toolkits list in their docs. A typo is **silent** — OpenTag does not +validate slugs against Composio at startup, so a misspelled toolkit is simply a +configured toolkit that never appears: the agent has no tools for it and +`search_my_tools` never mentions it. If an app you configured seems absent, +check the spelling before anything else. + +Adding Salesforce six months later is those same steps — no code, no test +change, no edit to `.railway/railway.ts` or the CDK stack. It is not zero-touch: +step 1 is a person in a dashboard and step 3 is a restart. + +`COMPOSIO_API_KEY` is the master switch. Without it nothing is constructed — no +SDK client, no session, no tool the model can see but must not call. A key with +both toolkit lists empty is equally inert. + +Composio is deliberately local-first. Its variables are not declared in +[`.railway/railway.ts`](./.railway/railway.ts) or in +[`deployment/aws/`](./deployment/aws), so a value set by hand on the Railway +`runtime` service is not carried across an IaC apply. Add a `preserve()` line +before you rely on it in a deployment. + +#### Shared team accounts versus personal ones + +`COMPOSIO_TOOLKITS` runs every Slack user through **one** connection, under the +Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` (defaulting to +`INTELLIGENCE_CHANNEL_NAME`). That is right for the team's Linear or Jira. + +`COMPOSIO_USER_TOOLKITS` scopes to the person speaking, keyed by their verified +platform actor id. You ask about "my calendar" and get yours; your colleague +gets theirs. A turn with no resolvable actor gets no personal tools at all and +never falls back to the shared identity. + +Both lists may be set at once, and one turn can use both. + +How an account actually gets connected differs by list, and this is where the +surprises are: + +- **Personal.** The agent posts a public **Connect** card carrying no link. + Whoever clicks it receives a one-time link privately, minted for them; someone + else clicking the same card connects their own account. A pre-minted link + posted in a channel would let whoever opens it bind their own mailbox to + another person's identity, so the link never appears in the thread. Where + there is no private channel — the Teams adapter implements no ephemeral post — + OpenTag says it cannot deliver the link rather than posting it publicly. +- **Shared.** There is no in-Slack path, by design. `connect_my_app` refuses a + shared toolkit, because the account would be created under the clicker's own + id while every shared call resolves under `COMPOSIO_WORKSPACE_USER_ID` — the + user would authorize, come back, and find nothing works. The operator + connects it once, from a terminal: + + ```bash + pnpm composio:connect linear + ``` + + It reads your local `.env`, mints a Composio Connect Link bound to the + resulting `COMPOSIO_WORKSPACE_USER_ID`, and prints it. Open that link + yourself, in a browser signed in to the account the whole team should act + through. The link is a bearer capability: whoever completes it *is* the + account every shared call runs as, so do not forward it. + + **The identity must match the deployed one.** `COMPOSIO_WORKSPACE_USER_ID` + defaults to `INTELLIGENCE_CHANNEL_NAME`, and Composio variables are set by + hand on the Railway `runtime` service (above) — so a local `.env` with a + different Channel name binds the connection to a `user_id` production never + looks up. Nothing errors: you authorize successfully, deploy, and the bot + still says the toolkit needs connecting. Set `COMPOSIO_WORKSPACE_USER_ID` + explicitly to the same value in both places, and confirm the `binds to:` line + the script prints is the one production uses. + + The script needs no running runtime — no `pnpm dev`, no agent — and never + guesses which auth config to use. A slug that is not in `COMPOSIO_TOOLKITS`, a + toolkit with no auth config yet, and a toolkit with several auth configs and + no pin each exit non-zero naming the fix. In the last case, pin one with + `COMPOSIO_AUTH_CONFIGS=linear:ac_...` and run it again; a pin is taken as + given and is not checked against the project, so a wrong id fails at Composio + rather than here. + +**"Connect my account" in the Composio dashboard is a test button.** It binds +the connection to the dashboard's own user id, which OpenTag never passes, so +the bot cannot see it and nobody on your team needs to touch it. Use +`pnpm composio:connect` instead — a shared toolkit is connected only when a +connected account exists under the exact value of `COMPOSIO_WORKSPACE_USER_ID`, +and that script is the only thing that creates one. + +Per-user isolation was verified against the live API, not assumed: with two +active connected accounts in a project, sessions created for four other ids all +reported every toolkit unconnected. Connections do not leak across identities. + +#### Approvals + +`COMPOSIO_APPROVALS` decides what stops for a human: + +| Mode | Behavior | +| --- | --- | +| `off` | Never asks. | +| `destructive` | **Default.** Asks only before tools Composio tags `destructiveHint`. | +| `writes` | Asks before anything not tagged `readOnlyHint` — how the Linear and Notion MCP integrations already behave. | + +Measured coverage: gmail exposes 63 tools of which 9 are destructive, linear 47 +of which 3, googlecalendar 49 of which 8. So `destructive` gates deletes and +their neighbours and leaves everything else silent. A slug OpenTag cannot +classify — past the 300-tool listing cap, or invented by the model — is treated +as destructive, so it is gated in every mode but `off`. + +A gated call runs in **two turns**, and that is worth understanding before you +set `writes`. The managed Intelligence adapter reports +`supportsBlockingChoice: false`, so nothing can block waiting for a click; the +tool posts a card and returns "stop here", and the Approve button executes and +rewrites the card in place. The consequence: **the model never sees the result +of a gated call** and cannot summarize or chain off it. Fine for a delete, where +"Done." on the card is the entire answer. On `writes`, every write becomes a +conversational dead end. + +**An approval card shows the arguments, in the channel.** Not the tool name — +the values. Every non-empty argument becomes a labelled row: recipients, the +subject, the body, up to 300 characters per row and 12 rows before the rest is +counted and elided. So gating "email that supplier from my Gmail" posts the +draft where everyone in the thread can read it, and the same is true of a +calendar invite's guest list or an issue's description. + +That is deliberate and it is not going to change: an approver who cannot see +what they are approving is a rubber stamp, and the card is the only place the +arguments are ever shown. But it is worth weighing before you put personal +toolkits behind approvals, because the person whose mailbox it is may not expect +the thread to see the draft. The dial is `COMPOSIO_APPROVALS`, and it is +all-or-nothing: on the `destructive` default only deletes are carded, so a sent +mail posts nothing; on `writes` every write is carded, arguments and all. There +is no per-toolkit or per-channel setting. Arguments are never written to logs +and never travel in the Slack button payload — the card is the only place they +appear. + +Each gated call posts its own card; a batch is not one card. A personal-scope +call may be approved only by the person it was composed for — otherwise Bob +approving Alice's delete would run against Bob's account — while a shared-scope +call may be approved by anyone in the thread. Pending calls live in memory, so a +card clicked after a restart answers `This approval expired — the bot restarted. +Ask again.` instead of executing or hanging. + +#### The three startup warnings + +OpenTag prints these once at boot and keeps running: + +- A personal-shaped app (`gmail`, `googlecalendar`, `outlook`, `googledrive`) in + `COMPOSIO_TOOLKITS`. Every Slack user will act through one mailbox. + Occasionally correct — a shared `support@` — usually a mistake. +- The same slug in both lists. The personal account wins and the shared entry is + dropped for that toolkit; on a turn with no resolvable actor, neither scope + offers it. +- A toolkit also configured over MCP — `LINEAR_API_KEY`, + `NOTION_MCP_AUTH_TOKEN`, `POSTHOG_PERSONAL_API_KEY`, or + `GITHUB_PERSONAL_ACCESS_TOKEN`. The agent then sees two complete tool sets for + that app with different approval behavior and may pick either, so whether an + action asks for approval varies per turn. Remove one. + +#### What Google shows your users + +Consent screens say **Composio**, not OpenTag. The Connect card says so up +front, because the alternative is a user deciding the bot is phishing them. + +Composio-managed auth is the default and the right choice. Self-branding is +**worse** until Google verification is finished: without it users get "Google +hasn't verified this app → Advanced → (unsafe)", which is a scarier screen than +a correctly named third party. Verification for restricted Gmail scopes is a +security assessment, not a form. + +Two consequences of managed auth: + +- All of one person's Composio connections share **one** Google OAuth grant. + Scopes accumulate across toolkits and the consent screen shows the union — + observed live as "Composio already has access to 12 capabilities". Revoking + Composio in Google account settings disconnects **every** app at once. +- Managed auth requests Composio's default scopes. You do not pick them. + +A Google Workspace that allowlists third-party apps can block the flow before it +ever reaches OpenTag. That is an admin action in Google, not in Composio, and +the symptom is a user who never gets past the consent screen. + +#### Restarts, caching, and logs + +OpenTag stores no credentials. Composio holds connected accounts server-side +keyed by `user_id`, so **a restart asks nobody to reconnect** — it empties an +in-process cache and nothing else. A cold session costs roughly a second +(session creation plus the tool listing); afterwards it is free. A cached +session carries a 10-minute TTL, and connecting an account drops that person's +entry outright — session, tool listing, and classification together — so the +next turn sees the new connection instead of waiting out the TTL. + +Every Composio call logs one line: slug, effect, the resolved `user_id`, and +Composio's own `logId` for correlating against their dashboard. Never the +arguments — that is where the mail bodies are. `execute()` does not throw on +tool failure, so a failed write returns its error verbatim rather than reading +as a success. + +Composio's remote sandbox — the tools slugged `COMPOSIO_REMOTE_BASH_TOOL` and +`COMPOSIO_REMOTE_WORKBENCH`, remote shell and Python; not environment variables +— is disabled on every +session without exception. A default session hands both out with no opt-in, and +OpenTag already has a sandbox in `agent/coding/` behind its own credentials. + +[`docs/composio-tools-design.md`](./docs/composio-tools-design.md) records the +design and the API findings verified against the live service. + ## Railway The IaC file declares exactly: @@ -347,7 +585,8 @@ Production Intelligence URLs are literal configuration, the API key is preserved, and the Channel name is `open-tag`. `AGENT_DISPLAY_NAME` is preserved independently on both services and must match when overridden. `OPENAI_API_KEY` is required on `agent`; Tavily, Daytona/coder, GitHub, PostHog, Linear, and the -paired remote Notion variables are optional preserved settings. +paired remote Notion variables are optional preserved settings. The `COMPOSIO_*` +variables are deliberately absent — see [Composio](#composio). Evaluate the configuration locally without applying it: From b99b54084cd5d8f0e0b80bf76aa15a0cb5a37fc6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 21 Aug 2026 20:56:16 +0200 Subject: [PATCH 4/4] chore(composio): keep the design and plan out of the repo Both are working documents. 0577c63 removed docs/superpowers specs and plans from this repository; this follows that call rather than reintroducing the same kind of file under a different path. They stay on disk for whoever is working on the feature. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 06c1d1e..f2ff65a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ deployment/aws/cdk.context.json # Subagent-driven-development scratch (ledger, briefs, review packages) .superpowers + +# Composio design and implementation plan — working documents, kept locally. +# The repo removed docs/superpowers specs and plans in 0577c63; same call here. +docs/composio-tools-design.md +docs/composio-tools-plan.md