From b43073d11fae2f62771144580a9c63a6734a4018 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:57:04 -0500 Subject: [PATCH] feat(connections): sync and group accounts managed by aggregators (#15254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connections give agents governed access to external tools. > - Aggregator gateways can expose accounts that users already connected upstream. > - The Apps catalog did not show those accounts or their current provider status. > - Separate cards and setup tasks also made account ownership unclear. > - This pull request discovers upstream accounts and groups them under one app card. > - Users can find connected apps while each provider keeps control of its accounts. ## Linked Issues or Issue Description **Subsystem affected** Connections across the database, shared contracts, server, and board UI. **Problem or motivation** Users cannot see which apps are connected through a saved aggregator gateway. Native and upstream accounts need one app card. Discovery must preserve company, user, gateway, and credential boundaries. **Proposed solution** Sync account metadata from Composio, Arcade, and supported Executor gateways. Keep upstream account management in each provider. Use source chips and search to browse the catalog. Preserve native setup and the gateway's existing access policy. **Alternatives considered** Creating a local executable connection for each upstream account would duplicate authorization state. Using an agent task for routine Composio setup would add an unnecessary step. The board now calls the saved gateway directly for that setup. **Roadmap alignment** This extends the shipped Connected Apps and MCP Tool Gateway features in ROADMAP.md. The duplicate search found no open PR for managed account discovery. Related work: Refs #13755, Refs #13941, Refs #14725, Refs #13855. Open PR #12906 covers adjacent toolkit routing work. ## What Changed - Add provider-neutral discovery, sync, and refresh APIs. Preserve the Composio API paths. - Cache observations by company, saved gateway, viewing user, and credential version. Retain stale observations after failed or incomplete scans. - Add optional Arcade account sync credentials in the vault. Discover Executor accounts through its supported inventory interface. - Group native and upstream accounts in one app card. Imported account menus open their provider. Gateway menus own refresh and sync setup. - Add Paperclip, Composio, Arcade, Installed, and All chips. Show 50 catalog entries per page. Keep connected accounts above discovery. Keep explicit provider searches scoped. - Simplify Composio app setup and refresh its connected app list on the gateway Permissions page. - Add a compact agent access card and task creation defaults for connection setup. Preserve explicit blocks and approval policies. - Add two replay-safe migrations, service and UI tests, Storybook journeys, and acceptance stories. ## Verification - Passed the repository typecheck, full build, token gates, and migration ordering check. - Passed the focused provider adapter, connection interaction, and catalog tests after rebasing onto master. - Passed all nine database sync and migration replay tests using a disposable database on the test-drive PostgreSQL cluster. Removed that database after the run. - Verified Arcade cursor pagination against its official Go SDK and passed all eight adapter tests, including short and incomplete pages. - Passed all 45 interaction tests after making the exact requested tools and their Allowed/Ask first permissions visible before granting access. Verified the compact card in Storybook. - Passed the complete UI suite on the final code: 683 files and 7,432 tests, including the corrected Composio destination assertions. Passed 130 focused tests for the UUID, management-link, and health-status corrections. - Passed 22 Composio setup/sync tests, 23 connection-intent service tests, and the connection migration test in separate disposable databases. Database startup alone was substituted; the suites exercised their real SQL and services. - Passed all 10 OpenAPI route checks and the full-stack connection-intent browser test, including scoped consent, agent continuation, and task completion. - The local full runner encountered embedded PostgreSQL startup failures on this loaded macOS host. The earlier in-flight run also held the pre-fix Arcade transform; a fresh run of the final provider suite passes. The final-head CI is queued during GitHub’s active Actions incident: https://www.githubstatus.com/. The previous run also lost several runners simultaneously; its real catalog assertion failures are fixed and the fresh complete UI suite passes. - Tested the real test-drive server in the embedded browser with a live Composio gateway. Detected Airtable and Circleback. Verified refresh progress, account rows, source chips, search scope, and 50-entry pagination. - Arcade and Executor coverage uses provider fixtures. Live credentials were unavailable. - Storybook builds successfully and includes grouped native/provider accounts, stale and unavailable discovery, optional Arcade setup, and mobile states. The acceptance document records the simulated and live coverage separately. - Greptile reviewed final commit `217b024c27b5933e773ce9419c4e92b1032042c6` at 5/5. All six review threads are resolved, security scans pass, and the PR has no merge conflicts. The outstanding remote checks are `ci / Select trusted runner` and `review`, queued by GitHub. They need to complete before merge. ## Risks - Provider response changes can break inventory discovery. Failed scans retain observations and show stale status. - Composio scans only the supported catalog and can take time. Large inventories run in the background with progress and a bounded lease. - Arcade requires a project API key and user ID when the gateway cannot supply them. This key is used only for discovery. - Executor discovery depends on the server's exposed inventory tools. Unsupported servers report unavailable discovery. - Cached account rows do not grant access or create executable connections. Gateway policies still govern tool use. Account deletion and per-app authorization remain upstream. - The migrations add tables and one nullable column. Replay preserves existing rows and company-scoped foreign keys. ## Model Used OpenAI Codex, based on GPT-6. The session does not expose a more specific serving model ID or context limit. Used reasoning, repository tools, code execution, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/CONNECTOR-PLAYBOOK.md | 155 +- .../2026-10-02-composio-connection-model.md | 164 ++ ...0-03-composio-account-sync-user-stories.md | 93 + ...managed-aggregator-account-user-stories.md | 86 + ...nnections-v3-schema-core-migration.test.ts | 3 + .../migrations/0295_public_captain_cross.sql | 21 + .../migrations/0296_stiff_thaddeus_ross.sql | 25 + ...{0291_snapshot.json => 0295_snapshot.json} | 171 +- ...{0290_snapshot.json => 0296_snapshot.json} | 691 ++++++- packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/index.ts | 2 + .../schema/tool_connection_app_snapshots.ts | 21 + .../src/schema/tool_connection_app_syncs.ts | 22 + .../shared/src/aggregator-app-catalog.test.ts | 48 + packages/shared/src/aggregator-app-catalog.ts | 95 + packages/shared/src/aggregator-apps.ts | 51 + packages/shared/src/arcade-app-catalog.json | 1602 +++++++++++++++++ packages/shared/src/composio-app-setup.ts | 64 + .../shared/src/connection-intent-guidance.ts | 3 +- packages/shared/src/connection-routing.ts | 15 + packages/shared/src/index.ts | 2 + .../shared/src/types/connection-intent.ts | 1 + packages/shared/src/types/issue.ts | 11 + .../src/validators/connection-intent.test.ts | 18 + .../src/validators/connection-intent.ts | 2 + packages/shared/src/validators/issue.ts | 10 + scripts/update-arcade-app-catalog.mjs | 48 + .../aggregator-app-discovery.test.ts | 65 + .../src/__tests__/aggregator-app-sync.test.ts | 160 ++ .../src/__tests__/composio-app-setup.test.ts | 337 ++++ .../connection-intents-service.test.ts | 105 ++ server/src/routes/connection-intents.test.ts | 2 + server/src/routes/connection-intents.ts | 4 +- server/src/routes/openapi.ts | 43 + server/src/routes/tool-access.ts | 64 + .../src/services/aggregator-app-discovery.ts | 142 ++ server/src/services/composio-app-setup.ts | 78 + .../src/services/connection-agent-access.ts | 73 + server/src/services/connection-intents.ts | 83 +- .../services/connection-tool-definitions.ts | 2 +- .../src/services/issue-thread-interactions.ts | 6 +- .../paperclip-runner-tool-authority.ts | 2 +- server/src/services/tool-access-policy.ts | 5 +- server/src/services/tool-access.ts | 462 +++++ .../tool-profile-binding-precedence.test.ts | 10 + .../tool-profile-binding-precedence.ts | 10 +- skills/paperclip/SKILL.md | 11 + skills/paperclip/references/api-reference.md | 18 + tests/aggregator-accounts/test-drive.ts | 81 + tests/e2e/connection-intents.spec.ts | 21 +- ui/src/api/client.test.ts | 18 + ui/src/api/tools.test.ts | 1 + ui/src/api/tools.ts | 21 +- .../components/IssueThreadInteractionCard.tsx | 11 + ui/src/components/NewIssueDialog.test.tsx | 116 ++ ui/src/components/NewIssueDialog.tsx | 44 +- .../TaskChatCompactInteractionCard.tsx | 6 - ui/src/context/DialogContext.tsx | 2 + .../ConnectionIntentInteractionBody.test.tsx | 55 + .../ConnectionIntentInteractionBody.tsx | 50 +- .../connections/ConnectionSetupFlow.tsx | 38 +- .../remote-mcp/RemoteMcpConnectionSetup.tsx | 22 +- .../remote-mcp/RemoteMcpProductionSetup.tsx | 64 +- .../connections/remote-mcp/providers.ts | 2 +- .../features/connections/remote-mcp/types.ts | 1 + .../issueThreadInteractionFixtures.ts | 19 + ui/src/lib/aggregator-app-setup.test.ts | 50 + ui/src/lib/aggregator-app-setup.ts | 52 + ui/src/lib/issue-execution-policy.ts | 29 +- ui/src/lib/queryKeys.ts | 2 + ui/src/lib/uuid.ts | 13 + ui/src/pages/apps/AggregatorAppManager.tsx | 58 + ui/src/pages/apps/AggregatorConnectDialog.tsx | 79 + ui/src/pages/apps/AppDetail.test.tsx | 168 ++ ui/src/pages/apps/AppDetail.tsx | 36 +- ui/src/pages/apps/AppsConnect.test.tsx | 150 +- ui/src/pages/apps/AppsConnect.tsx | 24 + ui/src/pages/apps/ArcadeDiscoverySetup.tsx | 42 + ui/src/pages/apps/Browse.test.tsx | 506 +++++- ui/src/pages/apps/Browse.tsx | 434 ++++- ui/src/pages/apps/CatalogSourceFilters.tsx | 15 + ui/src/pages/apps/ComposioAppManager.tsx | 66 + ui/src/pages/apps/ComposioAppSetup.tsx | 74 + ui/src/pages/apps/ExecutorManagementSetup.tsx | 33 + .../apps/app-detail/AgentConnectionAccess.tsx | 75 + .../app-detail/ConnectedAggregatorApps.tsx | 100 + .../prototypes/ConnectorCatalogNavigation.tsx | 186 ++ .../prototypes/ManagedAggregatorAccounts.tsx | 43 + ui/storybook/prototypes/README.md | 22 + .../apps-catalog-source-selection.stories.tsx | 64 + .../stories/apps-managed-accounts.stories.tsx | 6 + .../stories/in-feed-connections.stories.tsx | 27 +- 92 files changed, 7821 insertions(+), 225 deletions(-) create mode 100644 doc/plans/2026-10-02-composio-connection-model.md create mode 100644 doc/plans/2026-10-03-composio-account-sync-user-stories.md create mode 100644 doc/plans/2026-10-05-managed-aggregator-account-user-stories.md create mode 100644 packages/db/src/migrations/0295_public_captain_cross.sql create mode 100644 packages/db/src/migrations/0296_stiff_thaddeus_ross.sql rename packages/db/src/migrations/meta/{0291_snapshot.json => 0295_snapshot.json} (99%) rename packages/db/src/migrations/meta/{0290_snapshot.json => 0296_snapshot.json} (98%) create mode 100644 packages/db/src/schema/tool_connection_app_snapshots.ts create mode 100644 packages/db/src/schema/tool_connection_app_syncs.ts create mode 100644 packages/shared/src/aggregator-app-catalog.test.ts create mode 100644 packages/shared/src/aggregator-app-catalog.ts create mode 100644 packages/shared/src/aggregator-apps.ts create mode 100644 packages/shared/src/arcade-app-catalog.json create mode 100644 packages/shared/src/composio-app-setup.ts create mode 100644 scripts/update-arcade-app-catalog.mjs create mode 100644 server/src/__tests__/aggregator-app-discovery.test.ts create mode 100644 server/src/__tests__/aggregator-app-sync.test.ts create mode 100644 server/src/__tests__/composio-app-setup.test.ts create mode 100644 server/src/services/aggregator-app-discovery.ts create mode 100644 server/src/services/composio-app-setup.ts create mode 100644 server/src/services/connection-agent-access.ts create mode 100644 tests/aggregator-accounts/test-drive.ts create mode 100644 ui/src/lib/aggregator-app-setup.test.ts create mode 100644 ui/src/lib/aggregator-app-setup.ts create mode 100644 ui/src/lib/uuid.ts create mode 100644 ui/src/pages/apps/AggregatorAppManager.tsx create mode 100644 ui/src/pages/apps/AggregatorConnectDialog.tsx create mode 100644 ui/src/pages/apps/ArcadeDiscoverySetup.tsx create mode 100644 ui/src/pages/apps/CatalogSourceFilters.tsx create mode 100644 ui/src/pages/apps/ComposioAppManager.tsx create mode 100644 ui/src/pages/apps/ComposioAppSetup.tsx create mode 100644 ui/src/pages/apps/ExecutorManagementSetup.tsx create mode 100644 ui/src/pages/apps/app-detail/AgentConnectionAccess.tsx create mode 100644 ui/src/pages/apps/app-detail/ConnectedAggregatorApps.tsx create mode 100644 ui/storybook/prototypes/ConnectorCatalogNavigation.tsx create mode 100644 ui/storybook/prototypes/ManagedAggregatorAccounts.tsx create mode 100644 ui/storybook/stories/apps-catalog-source-selection.stories.tsx create mode 100644 ui/storybook/stories/apps-managed-accounts.stories.tsx diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index cd2db9348c..6c94bd6124 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -648,7 +648,7 @@ controls and precise labels over explanatory paragraphs. Remove repeated headings, redundant access summaries, implementation details, and reassurance that does not help the user decide or act. Keep necessary warnings, meaningful consequences, and actionable errors. Put optional expert settings under a -collapsed Advanced disclosure. Link to provider-owned administration, such as +collapsed disclosure through **Change**. Link to provider-owned administration, such as AgentMail allowlists, rather than rebuilding it in Paperclip. **Keep ongoing interactions in Paperclip tasks.** Connections are where users @@ -1511,11 +1511,16 @@ The operator should see Apps, Connections, and Review language. Keep protocol la Request only the documented scope set the reviewed connection needs; never adopt every scope returned by discovery. Operators should not have to predict every future tool during setup. Keep the default view to the minimum inputs -needed for a working connection, fold optional expert controls under one -collapsed **Advanced** disclosure, and enforce execution afterward through +needed for a working connection, fold optional expert controls under the +**Change** link beside the access summary, and enforce execution afterward through Paperclip's resource boundaries, risk classification, tier defaults, optional quarantine, and audit. +When a provider supplies its MCP endpoint, keep that URL out of the initial +form. Composio exposes it through **Reuse an existing session** for operators +with a custom session URL; resumed custom endpoints and URL validation errors +keep the field visible. Preserve the endpoint when its disclosure is closed. + ### Step 8: Apply Governance Defaults Governance is automatic because every catalog entry becomes a normal tool-access object: @@ -2326,3 +2331,147 @@ in-memory provider responses. The `provider-native`, `provider-decline`, and `provider-second` Product E2E cases exercise native preference, persisted choice, restart recovery, and an independently observed gateway read. They do not prove compatibility with the real external providers. + +### Public aggregator apps in Connectors + +The Connectors catalog combines native definitions with the public Composio and +Arcade app snapshots in `packages/shared/src/aggregator-app-catalog.ts`. Native +definitions always take precedence, including definitions temporarily hidden or +unavailable on the instance. Each remaining app has one card with its provider +logos; Connect opens a provider picker when multiple providers support the app. +An app with one provider goes directly to setup or its saved gateway flow. +Search covers names, aliases, providers, +and saved accounts. The uninstalled catalog uses pages of 50 entries; matching +installed connectors remain above every page. Listing an upstream app never +creates a Paperclip connection or marks that app authorized. +Provider filters include native cards only when they contain accounts managed +by that provider. A matching public catalog entry alone does not include a +native card; its native onboarding remains in Paperclip and All. + +Composio app setup offers a dropdown of connected Composio accounts and an option +to connect a new one. The board calls `COMPOSIO_MANAGE_CONNECTIONS` directly to +check the requested toolkit and generate its hosted authorization link. This +creates no task and starts no agent run. An already active app skips new link +creation. After the human signs in, completion checks the toolkit's ACTIVE +account status again. New gateways default to all humans and all agents; app +setup preserves the selected gateway's saved access and tool policies without +adding agent-specific grants. Access remains gateway-wide, not app-level +isolation. Saved credentials are resolved for the acting human and authorization +URLs stay outside audit/result storage. A failed or uncertain add is never +retried automatically; the user can explicitly request a new link. + +Composio is authoritative for app accounts and authorization. Paperclip stores +only account observations in `tool_connection_app_snapshots`: IDs, aliases, +statuses, default flags, check times, and failure times. Observations are scoped +to company, saved gateway, viewing human, and credential identity, including +secret versions. Credential replacement or rotation hides the old inventory. +Discovery and app setup never create agent grants or change saved policies. + +Apps starts a background check of the entire supported public Composio catalog, +independent of search and pagination. `POST /tool-connections/:id/composio/apps/sync` +returns cached observations and sync progress; `{force:true}` requests a refresh. +`tool_connection_app_syncs` holds a per-credential lease and progress. Repeated +visits and concurrent tabs share the lease. Checks use explicit `action:list`, +batches of 32, a four-minute deadline, and five-minute freshness. Known accounts +are checked first. Polling updates account rows while browsing stays usable. +Manual **Refresh Composio** belongs in the saved Composio account's kebab menu; +it checks that account only. Do not add a catalog-wide refresh button. +Composio gateway setup finishes on its Permissions page. That page lists detected +apps in a bounded, scrollable list and starts a refresh on first load. Its +**Refresh Composio** button shows catalog-check progress and refreshes only that +gateway. The same connected-app list and refresh controls serve Arcade and +Executor gateways when discovery is available. Executor remains available as a +connection, but its catalog filter chip is temporarily hidden. +A complete empty list removes an observed account; EXPIRED accounts show Needs +sign-in. Failed or incomplete evidence retains the last accounts, visibly +unverified, and offers retry. Interrupted jobs can be restarted after the lease +expires. A late response cannot replace a newer check or a changed credential. + +The saved Connect MCP OAuth credential supports listing accounts by toolkit; +it is not a consumer user API key or a developer project API key. This discovery +covers the supported public catalog, including toolkit variants. It does not +claim complete enumeration of custom apps outside that catalog. A future +identity-bound inventory endpoint should replace this scan when available. +Never forward an MCP OAuth token to an unrelated REST inventory endpoint. + +Imported accounts appear above the paginated catalog with their actual source: +**Via “saved connection name”**, rather than attributing upstream authorization +to the gateway creator. Already connected Composio accounts remain visible even +when Paperclip has a native connector; native Connect offers still take +precedence. Imported account menus offer only **Open in Composio**. Access +controls remain on the saved gateway and apply to all apps on that connection. +The Manage dialog displays provider observations +and links to Composio; it does not offer local rename or upstream removal. +Use `https://dashboard.composio.dev/~/org/connect/apps` for Connect app management. +The organization placeholder resolves to the signed-in user's For You area. +The bare dashboard opens Platform developer projects, which have a separate +account inventory and are the wrong destination for saved Connect MCP accounts. +Remove connection remains available on the saved gateway itself. Its confirmation +states that removing it from Paperclip does not delete accounts in Composio. + +Targeted Composio app setup URLs reuse the saved-account chooser when an active +gateway exists. Explicit `new=1`, resume, and reconnect keep their own gateway +setup flow. Existing ACTIVE app accounts are checked without creating another +hosted link or a task. New app authorization remains a direct hosted sign-in +handoff, followed by a fresh account check. + +Arcade still offers an agent task draft to verify the requested app and obtain +any provider authorization link. It requires the app's tools in the selected +gateway, a catalog refresh, and any tool authorization. New gateway setup retains +the app name through `targetToolkit`; it creates only the provider gateway. +Composio then returns to direct app setup; Arcade opens the task draft. +Setup entry points preselect an assignable agent: prefer an agent named +**Default agent**, then organizational rank and leadership roles, then creation +date. If there is no named default, use the same rank and age ordering on the +remaining agents. The user can change the selection before continuing. +The board's Create Task action creates a new task even when an open task has the +same title. A request key protects retries of the same submitted draft, and the +success confirmation links to the created task. +Connection setup drafts opt into the dialog's `navigateOnCreate` option, taking +the operator to the created task after submission. Other callers stay on their +current page by default; the option does not add a visible control. +The task asks the assigned agent to perform setup directly without hiring or +delegating, and to provide a browser link and wait when human authorization is +needed. Before provider calls, it checks access for the task's current assignee. +Missing access uses `connection_request` with the saved `connectionId` and exact +indexed `toolNames`, producing an embedded card with the requesting agent's +avatar and a **Grant access** action. The addressed human connection manager +approves the frozen tool set; acceptance adds agent-scoped access and resumes +the task. Composio setup asks for Search Tools as Allowed and Manage Connections +as Ask first. Writes and unknown-risk tools retain per-call approval. Changed or +quarantined tools require a new request. Existing policies, identity selection, +and other agents' access are preserved. The task never grants itself permissions +or requests access for every agent. +The connection's Permissions page lists additional agent access separately +and lets the connection manager remove it without changing the default action +permissions. Removing that grant also disables its Ask-first tools. +A successful agent run or a local gateway fixture does not prove that +the underlying app is authorized; verify against the real provider gateway. +OAuth recovery exposes a validated native sign-in link on both page and dialog +hosts. A blocked window or navigation retains the existing connection and OAuth +session so the link can continue sign-in without creating another gateway. + +Composio reuses `composio-search-catalog.json`. Arcade's snapshot records its +official logo and evidence URLs, excluding hidden and coming-soon entries. To +review and refresh only Arcade's claims, run: + +```sh +node scripts/update-arcade-app-catalog.mjs --verified-at YYYY-MM-DD +``` + +Use the date of the public catalog review. The script preserves the prior file +if the source structure or entries fail validation. `--input ` allows +reproducing the snapshot from the same official catalog response. Neither public +snapshot proves a user's gateway configuration or app authorization. + +### Managed aggregator account inventory + +Composio, Arcade, and Executor observations share the existing app snapshot/sync tables and the manager-only `/api/tool-connections/:connectionId/aggregator/apps` list, `/sync`, and `/refresh` endpoints. Legacy Composio endpoints remain supported. These rows are observations, never executable connections or authorization grants. Company, gateway, viewing human, and effective credential version define the cache; fully successful enumeration reconciles removal, while partial failure retains stale observations. + +Arcade discovery paginates its [admin account list](https://github.com/ArcadeAI/arcade-go/blob/main/adminuserconnection.go) and [tool requirements](https://github.com/ArcadeAI/arcade-go/blob/main/tool.go), filters to the configured user and exposed gateway tools, and stores only allowlisted account metadata. An existing project key plus `Arcade-User-ID` can be reused. Otherwise optional manager-only sync setup stores a separate user-owned vault key for discovery; it is excluded from gateway invocation credentials and tool access grants. OAuth gateway tokens are not substituted for project keys. + +Executor reads its [MCP integration inventory](https://github.com/UsefulSoftwareCo/executor/blob/main/packages/hosts/mcp/src/tool-server.ts) or uses fixed, read-only `connections.list` and `integrations.list` calls in code mode. Its pure `connections.createHandoff` URL builder can supply the upstream console destination. No arbitrary generated code or provider REST endpoint is used. Unknown integrations remain separate, without guessed branding. Last-health `healthy` is connected, `expired` needs sign-in, and unverified or degraded health never earns a green check. Missing inventory support is visible as discovery unavailable. Managers can set a trusted HTTPS console URL for gateways that do not expose a destination, preserving workspace and self-hosted paths. + +Apps groups native and imported accounts under canonical app cards, preserving all upstream account identities. Native onboarding wins over aggregator connect offers. Imported account menus only open their upstream provider; deletion and per-app authorization stay upstream. Parent gateway menus own refresh and optional discovery setup. Paperclip policies continue to govern invocation through the gateway. + +Acceptance fixtures: `server/src/__tests__/aggregator-app-discovery.test.ts`, `server/src/__tests__/aggregator-app-sync.test.ts`, `ui/src/pages/apps/Browse.test.tsx`, and the production-page/full-stack test drive at `tests/aggregator-accounts/test-drive.ts`. The expected stories and evidence are recorded in `doc/plans/2026-10-05-managed-aggregator-account-user-stories.md`. Fixture runs do not replace live provider proof when working credentials are available. diff --git a/doc/plans/2026-10-02-composio-connection-model.md b/doc/plans/2026-10-02-composio-connection-model.md new file mode 100644 index 0000000000..9fcf37ae5c --- /dev/null +++ b/doc/plans/2026-10-02-composio-connection-model.md @@ -0,0 +1,164 @@ +# Composio connections and permissions in Paperclip + +October 2, 2026 · Current behavior and proposed product model + +**Composio should be the source of truth for its app accounts. Paperclip should show those accounts and control who can use the saved Composio connection.** Connecting Circleback in Composio should not require connecting it again in Paperclip when the saved gateway can already reach that account. + +The current integration supports direct app authorization and checking known accounts, but discovery is incomplete. Its Circleback cards also look like independent Paperclip connections even though their permissions belong to the entire Composio gateway. This document explains that distinction and proposes a clearer experience. Recommendations below are proposals; this document does not change product behavior. + +## What the saved connection actually represents + +The default integration uses Composio Connect at `https://connect.composio.dev/mcp`. Composio describes this as one MCP connection that discovers and executes tools across apps through orchestration tools. It is different from an application creating its own Composio SDK session. [Composio Connect documentation](https://docs.composio.dev/docs/composio-connect) + +There are three objects to distinguish: + +| Object | Meaning | +| --- | --- | +| Saved Composio connection in Paperclip | An endpoint, credentials, access rules, and tool catalog. We have called this a gateway. Multiple saved Composio connections are possible. | +| Connected app account in Composio | A particular authorized Circleback, Spotify, or other app account. | +| App row in Paperclip | A cached observation of an account available through a saved gateway, plus a convenient setup or management entry point. | + +**An observed Circleback row does not create another credential or independent connection in Paperclip.** The public app catalog is another separate thing: it says Composio supports Circleback, without proving that this gateway has an authorized Circleback account. + +Composio accounts are scoped. Projects separate resources, and connected accounts belong to an upstream user identity. Dashboard-created accounts use a Composio dashboard user identity, which is different from an application's `user_id`. Matching a person's email or using the same Composio brand is insufficient to establish that two integrations reach the same accounts. [Project isolation](https://docs.composio.dev/reference/api-reference/projects), [Connected account identities](https://docs.composio.dev/reference/api-reference/connected-accounts) + +For our existing Connect gateway, the practical question is: **Does listing Circleback through this exact saved credential return the account?** That check is stronger evidence than seeing Circleback somewhere in the Composio dashboard. The current integration has not established a general mapping from Connect OAuth identity to every dashboard project. + +## What happens to tools + +Paperclip does expose the gateway's tools to permitted agents. It reads the upstream MCP `tools/list`, stores the returned definitions, presents eligible tools through its own runtime, checks policy, and forwards execution to the saved endpoint using server-resolved credentials. + +That is a proxy of the upstream tool catalog. It does **not** turn every app in our public catalog into its own set of executable Paperclip tools. + +```mermaid +flowchart LR + Agent --> Policy[Paperclip gateway access and tool policy] + Policy --> Tools[Composio MCP tools] + Tools --> Accounts[Composio authorized app accounts] + Accounts --> Circleback[Circleback] + Apps[Paperclip Apps page] --> Check[Read account status through saved gateway] + Check --> Accounts + Check --> Cache[Safe account observations] + Cache --> Apps +``` + +The live test gateway currently has 11 active Composio catalog entries, including search, schemas, connection management, batch execution, and workbench tools. It does not list a separate Circleback tool set. Circleback execution happens behind those Composio tools. A custom session endpoint could expose a different catalog; the actual upstream definitions determine what Paperclip presents. + +Consequently, an account can be available for execution even before the Apps page discovers it. The account snapshot is a display cache, not an execution allowlist. + +## What currently works and what is incomplete + +These findings come from the current worktree and a read-only check of the local test gateway. + +| Capability | Current behavior | +| --- | --- | +| Reuse an already connected app | Setup lists the requested toolkit first. An active account skips creating another authorization link. No agent task is necessary. | +| Authorize an app from Paperclip | The backend requests a hosted Composio authorization link. Completion checks account status again. | +| Recognize a known account | Safe account IDs, aliases, statuses, default flags, and check times are cached. A fresh check confirmed Circleback is active. | +| Discover an app connected independently | The Apps page checks its visible catalog toolkits and previously observed or configured toolkits. A previously unseen app can remain undiscovered until its page or search is checked. | +| Notice external disconnection | Rechecking a known toolkit replaces the observation with the provider's returned account list. Until then, the card can be stale. | +| Rename or remove an app account | Paperclip calls Composio, then lists accounts again to confirm the change. This changes upstream state. | +| Give Circleback its own agent permissions | The row's Permissions action opens the saved Composio gateway's permissions. It is not an independent Circleback permission boundary. | + +Refresh happens on page entry, changes to the visible catalog query, and browser focus. The one-minute query freshness setting is **not** a background polling schedule. We have no full inventory reconciliation job or lifecycle webhook integration here. + +If a provider check fails, we preserve previous observations and show an error. That avoids interpreting a failed request as a disconnection, but the old green check can still look current. The UI should distinguish **last confirmed connected** from **verified now**. + +Two additional presentation gaps matter: + +- Native connector precedence currently excludes overlapping aggregator cards altogether. A native Notion offer can therefore hide the fact that Notion is already available through Composio. We should suppress duplicate connection offers while still showing real upstream accounts and their source. +- The aggregator row's “Connected by” identity comes from the saved gateway owner metadata. It does not establish who authorized the app independently in Composio. Preserve the account-row visual style, but use truthful attribution such as **Via “Work Composio”** unless the upstream author is known. + +## Who owns which permissions + +| Question | Authority | +| --- | --- | +| Is this app account authorized and active? | Composio, backed by the app's authorization. | +| What access did the app's OAuth consent grant? | The app and Composio's authorization configuration. | +| Can this Paperclip human or agent use the saved gateway credential? | Paperclip's grants and access controls. | +| Is this exposed MCP tool allowed, denied, or subject to approval? | Paperclip's tool policy. | +| Can this agent use only Circleback through a broadly enabled Composio executor? | Not established by the Circleback app row or its current Permissions link. | + +New gateways now default to all humans and all agents. That makes the gateway available to those actors; it does not override credential requirements, tool policies, or Composio's own account restrictions. App setup preserves the saved gateway's existing rules. + +The policy system evaluates the catalog entry being invoked. For example, it sees `COMPOSIO_MULTI_EXECUTE_TOOL`; it does not automatically expand every nested app action into a separate Circleback permission decision. The current integration has no Composio-specific layer that grants or denies downstream toolkit/account operations independently. + +An approval for a broad execution tool is therefore an approval for that call and its arguments. It should not be presented as a verified per-app read/write policy. Generic argument rules can constrain particular shapes, but do not by themselves provide comprehensive app isolation across batch execution and workbench paths. + +Likewise, an app card's absence, a native app's precedence, or removing a cached observation cannot stop a permitted gateway from reaching an upstream account. A visual catalog choice is not a runtime restriction. + +## What connecting or disconnecting elsewhere should mean + +The following is the proposed behavior, assuming the account belongs to the identity and scope reachable by the saved gateway. + +| Event | Expected Paperclip behavior | +| --- | --- | +| Connect Circleback in Composio | Import it into connected apps automatically. No second OAuth flow or local activation step. | +| Click Connect when Circleback is already active | Verify and show the existing account. Do not create another account. | +| Disconnect Circleback in Composio | Update the account row after reconciliation. Provider execution follows upstream state even while our card is stale. | +| App authorization expires | Show Needs sign-in and offer the provider's reauthorization flow. | +| Composio cannot be reached | Keep the last observation, mark it stale, and show Check again. Do not claim a confirmed disconnection. | +| Add a second account for the same app | Show both accounts. Do not imply that clicking one pins all later agent calls to it unless execution actually enforces that choice. | +| Remove Composio from Paperclip | Remove local access to that saved gateway. Keep upstream app accounts in Composio. | + +Composio documents automatic OAuth token refresh and an expired-account event. That gives us one potential sync signal, but does not prove that our Connect credential can subscribe to every creation, deletion, or status event. [Authentication lifecycle](https://docs.composio.dev/docs/authentication) + +## The experience I recommend + +**Keep one saved connection per Composio account or configured endpoint, show its apps as imported accounts, and manage app authorization in Composio.** This matches the underlying system and keeps our interface small. + +1. **Connecting Composio imports its available accounts.** Put them above the catalog using the same divider, check, logo, and account-row layout as other connections, with a Composio badge and quoted gateway name. Label this inventory as connected apps, rather than apps separately installed into Paperclip. +2. **Connect asks which saved Composio account to use, or offers a new one.** Check existing authorization first. If needed, open a provider-generated sign-in link directly and verify the result. No agent picker or task draft. +3. **An app's Manage action shows its accounts and opens Composio management.** Prefer a toolkit/account-specific URL where supported and verified; otherwise link clearly to Composio. Do not promise a deep link that the provider does not expose. +4. **Permissions live on the Composio gateway.** From an imported app row, call the action **Composio permissions** and explain once that changes apply to apps accessible through that gateway. Keep native connector permissions independent. +5. **Manage upstream app removal in Composio for the initial model.** Reserve **Remove connection** in Paperclip for removing the saved gateway locally. If we retain direct upstream removal, name it **Disconnect from Composio** and explicitly say it can affect other clients using that account. + +This is a UX recommendation, not a technical inability to remove accounts. Composio Connect supports account creation, listing, renaming, and removal, and our backend already implements those operations. [Connection management capabilities](https://docs.composio.dev/docs/composio-connect) + +Removing an account from Composio should also not be described as guaranteed revocation of the target app's OAuth grant. Composio distinguishes removing an account from revoking its authorization at the provider. [Connected account lifecycle](https://docs.composio.dev/reference/api-reference/connected-accounts) + +For native overlaps, keep the native Connect offer as requested, but include actual Composio accounts in the connected inventory with their source visible. A Composio account should never silently become a native account. + +## How to make discovery complete + +The current per-toolkit checks are useful, but cannot support the promise “we automatically import everything you already connected.” Scanning thousands of catalog toolkits is an expensive substitute and can still miss apps absent from our public catalog. + +Composio provides a paginated connected-account API with project-key authentication and user/toolkit/status filters. Its SDK also offers paginated session toolkit discovery filtered to connected apps. These are better building blocks for inventory than probing every public catalog entry. [Connected-account API](https://docs.composio.dev/reference/api-reference/connected-accounts/getConnectedAccounts), [Session inventory](https://docs.composio.dev/docs/configuring-sessions) + +**The unresolved integration question is whether we can enumerate that same inventory using the identity behind our current Connect OAuth gateway.** We must not assume that credential is a project API key or that a dashboard project's users match it. Listing every account in a project without the correct identity filter would not answer the user's question. + +Proposed implementation order: + +1. Establish a supported inventory API for the saved credential, including its upstream subject, project/session scope, and shared-account semantics. If Connect cannot provide this, evaluate an explicit project/session integration as a separate setup mode. +2. Reconcile every inventory page on connection completion and refresh; add bounded background reconciliation and supported event notifications. Events trigger another authoritative read rather than becoming a second source of truth. +3. Store only safe metadata, scoped to company, gateway, and resolved credential identity. Preserve last-success time and sync errors. Change credentials or upstream identity without carrying over another identity's connected status. +4. Reconcile removals only after a complete successful listing. Partial pages and failed calls must not delete unseen accounts. Surface unknown custom apps using provider metadata where available. + +Until the identity-bound enumeration route is proven, describe the current display as **apps checked through this connection** rather than complete synchronization. The current cache uses a credential-reference fingerprint; that is not proof of the upstream user's identity. + +## If we later want independent app permissions + +We should decide this separately from inventory. The simpler gateway model is valid, provided the interface is honest about its scope. + +Composio SDK sessions can restrict toolkits, tools, and selected connected accounts. A session MCP endpoint can use those restrictions and a direct-tools preset to expose an explicit tool set. That offers a possible route to enforced per-agent scope, but it requires a different integration contract from merely observing accounts behind Connect. [Session configuration](https://docs.composio.dev/docs/configuring-sessions), [Session MCP endpoints](https://docs.composio.dev/docs/sessions-via-mcp) + +The other route is a Paperclip executor that validates every downstream tool and account and prevents broader execution paths from bypassing those rules. This is substantial runtime work. Adding a per-app toggle while leaving unrestricted gateway execution available would not establish the promised boundary. + +For apps already supported natively, a native connection remains the clearest way to apply that connector's individual access controls. + +## Evidence and checks still needed + +The local gateway returned 11 active Composio tools, and a fresh read through our app refresh endpoint confirmed an ACTIVE Circleback account. This proves account-status checking for that saved credential. It does not prove all-app enumeration, a successful Circleback business operation, external lifecycle synchronization, or independent Circleback permissions. No real account was disconnected or permission changed for this document. + +Before claiming complete sync, demonstrate an app connected independently in the matching Composio scope appearing without searching for it; then externally disconnect a disposable test account and verify both the UI and execution outcome. Also check account isolation, multiple-account selection, provider failures, native overlaps, and gateway removal without upstream account deletion. + +Implementation evidence in this worktree: + +- `server/src/services/tool-access.ts`: `openComposioGateway`, `setupComposioApp`, `refreshComposioApps`, and `manageComposioAppAccount` implement checks, safe observations, and upstream mutations. +- `server/src/services/tool-gateway.ts`: `policyInputForAgentTool` and remote MCP execution show the tool-policy boundary and forwarding of catalog tool names and arguments. +- `server/src/services/tool-access-policy.ts`: catalog entries and saved connections determine the policy context; no automatic downstream Composio app expansion is implemented. +- `ui/src/pages/apps/Browse.tsx`: native precedence, page-based discovery, connected-account rows, permission links, and owner attribution. +- `packages/db/src/schema/tool_connection_app_snapshots.ts`: cached account observations, separate from connection grants. +- `doc/connections/CONNECTOR-PLAYBOOK.md`: the current aggregator catalog and setup behavior. + +The recommendation is to adopt the provider-owned account model now, then prove complete discovery before promising it. Independent app permissions can follow if we choose to build and enforce them. diff --git a/doc/plans/2026-10-03-composio-account-sync-user-stories.md b/doc/plans/2026-10-03-composio-account-sync-user-stories.md new file mode 100644 index 0000000000..2ee272454d --- /dev/null +++ b/doc/plans/2026-10-03-composio-account-sync-user-stories.md @@ -0,0 +1,93 @@ +# Composio account sync user stories + +October 3, 2026 · Acceptance criteria written before implementation; results recorded after the embedded-browser walkthrough + +The user starts on Apps with a saved Composio gateway. Composio owns app accounts and authorization; Paperclip shows provider observations and controls access to the saved gateway. Existing access restrictions must survive every discovery and setup action. + +The current Connect MCP credential supports account listing by toolkit, not an authenticated list-all inventory API. Automatic discovery therefore checks the supported Composio catalog in bounded background batches, independently of search and pagination. Custom apps absent from that catalog remain outside this coverage. The UI and final test report must describe that limit. + +## Expected journeys + +| Story | Expected outcome | Independent evidence | +| --- | --- | --- | +| As a returning user, I see apps already authorized in Composio without searching or reconnecting them. | Apps begins syncing the saved gateway. Accounts discovered anywhere in the supported catalog move above the paginated catalog, with their app logo and Composio source. | Provider fixture has a pre-existing account outside the first catalog page; no add call, task, new gateway, or grant is created. | +| As a user, I reuse an existing app authorization when I click Connect. | Select a saved Composio account, continue, and see its existing app account. No second sign-in link. | Provider calls contain list only for the already active app. | +| As a user, I authorize a missing app directly. | Choose the saved account, open the hosted provider link, complete sign-in, and return to verify. No agent or task picker. | Provider account changes after authorization; completion reads it again. | +| As a user, I see an app I connected independently. | Automatic sync or Refresh discovers it without finding its catalog page. | Change the disposable provider's account state independently; use the production Apps UI to refresh. | +| As a user, I see an app disconnected or expired independently. | A complete account check removes the disconnected account or shows Needs sign-in for the expired one. | Provider returns an empty list or EXPIRED account; the old green Connected state disappears. | +| As a user, I manage Composio app accounts in Composio. | Manage lists the observed accounts and offers Open in Composio. No local rename or remove action that silently changes upstream state. | Open the row menu and management dialog; inspect the external link and confirm zero rename/remove calls. | +| As a user, I understand whose permissions I am editing. | Imported app menus say Composio permissions and open the saved gateway. A short explanation says access applies across this gateway's apps. | Navigation goes to the gateway's existing permission screen; native connectors retain Permissions. | +| As a user, I see native and Composio account sources accurately. | Native Connect remains preferred; an already authorized upstream account remains visible. Attribution says Via the quoted gateway name rather than inventing an upstream author. | Seed a native-overlap app upstream; inspect its card, account row, and source link. | +| As a user, I can recover from a failed check without losing accounts. | Keep prior observations with a visibly unverified status; show a useful retry action. A successful retry restores verified status. | Provider failure followed by success; no account deletion or false disconnected result. | +| As a user with several Composio gateways, I see accounts grouped by their actual source. | Show each source separately and preserve selection in setup/management. | Distinct provider accounts behind two saved gateways; calls use the selected gateway. | +| As a user, I remove the local Composio gateway without deleting its upstream apps. | Confirmation states that Composio accounts remain. Saved gateway access and its imported rows disappear locally. | Use a disposable gateway; provider accounts remain and no upstream remove/revoke-app call occurs. | +| As a user, I can browse and search a large catalog while sync runs. | Installed accounts stay above each catalog page. Sync feedback does not block search, pagination, or cancel. | Search and change pages during sync; inspect persistent accounts and bounded page size. | + +## Verification boundaries + +Use the embedded browser against the built production UI and real Paperclip server/database. Live checks use the existing Composio test gateway for reads and management navigation. Authorization, expiry, independent disconnection, errors, and local removal use a clearly marked disposable provider fixture so valuable real accounts are preserved. + +Fixture results establish Paperclip behavior through the real UI and backend, not real Composio authorization compatibility. Record these separately. A passing unit test or API response alone does not pass a browser journey. + +## Results + +The implementation and all twelve Paperclip browser journeys passed within the boundaries below. Fresh third-party OAuth consent was simulated through the disposable provider; it is not claimed as a live provider pass. Live reuse and discovery were verified with the saved Composio gateway and independently compared with Composio's own account inventory. + +### Live Composio + +Tested the built Apps UI at `http://localhost:3104/TES/apps`, using the existing **Composio test gateway**. No real account was removed, renamed, or newly authorized, and no agent permissions were changed. + +- Automatic discovery checked all **1,583 catalog toolkit identifiers**, including aliases and native overlaps, with zero failed toolkit checks. Circleback and the independently connected Airtable account appeared above the paginated catalog without searching for them or requesting another sign-in. +- Opening the old `/TES/apps/connect?source=composio&targetToolkit=circleback_mcp` URL selected the saved gateway. Continue reused the active Circleback account, closed setup, and cleared the setup query. Reload did not reopen the dialog. No new task or gateway was created. +- Manage Circleback showed the provider account and its default status, the quoted source connection link, and the explanation that Paperclip permissions apply across this gateway. The source link opened the correct existing permission screen. Native Notion retained **Permissions** and **Remove connection**. +- **Open in Composio** was clicked from Paperclip. Its final destination was the consumer **For You → Connect Apps** screen, rather than Platform developer projects. Selecting Connected showed the same two apps: Airtable and Circleback MCP, both with one active account. +- A subsequent background check failed at 448 of 1,583 identifiers. Previously observed accounts remained visible as unverified. Refresh recovered, and the next complete scan again checked 1,583 identifiers with zero failures. The failure's provider/transport cause was not established; it is not presented as an app disconnection. + +### Disposable provider journeys + +Used the production React build, company-scoped tool-access routes, service, and real local PostgreSQL database at `http://localhost:3110/COM/apps`. Only outbound responses for two explicitly fictional fixture gateways were controlled through the service's existing remote-request test seam. This was a separate company with a paused agent and no issues. + +| Journey | Browser result and corroboration | +| --- | --- | +| Pre-existing apps without search | Circleback accounts behind two gateways, imported Notion, and Zendesk outside the first catalog page appeared automatically. No add request, task, or agent grant was needed. | +| Existing authorization | Live Circleback reuse passed as described above; setup checked the existing account before deciding whether authorization was needed. | +| Missing app authorization | Spotify opened the saved-account dropdown with Work, Personal, and Connect a new account. No agent picker appeared. Continue produced the hosted sign-in link. Premature completion showed a useful “finish connecting” error. After independent fixture activation, completion verified the account and displayed its connected row. The fictional hosted link itself was not opened. | +| Independent connection | Added Spotify and Airtable in provider fixture state, then clicked Refresh. Both appeared without using their native Connect actions. | +| Independent disconnection and expiry | Removed Personal Circleback and Zendesk upstream; expired Work Circleback. After sync, the removed accounts disappeared, Zendesk returned to Connect, and Circleback showed Needs sign-in rather than a green check. | +| Provider-owned management | The imported row menu contained only Open in Composio and Composio permissions. Manage was an observation view with Refresh and the provider link. Recorded provider actions contained no rename or remove operation. | +| Permission ownership | Composio permissions navigated to the actual saved gateway. Its explanation appeared before the human/agent permission controls. No permissions were changed by discovery or setup. | +| Native overlap and source attribution | The Airtable card retained native Connect while displaying its already connected upstream account. Imported Notion remained visible. Imported rows said Via the quoted gateway name; native rows retained Connected by. | +| Failed refresh and recovery | Injected HTTP 503, clicked Refresh, and saw the last known accounts with Not verified and retry. Restored the provider, clicked retry, and saw verified Connected again. | +| Several gateways | Circleback showed two distinct accounts and source links. Management switched between Work and Personal; after Personal's upstream removal it correctly showed no accounts, without displaying Work's account under Personal. | +| Local gateway removal | Cancel preserved the gateway. Confirming Remove archived Work and revoked its local organization grant; Work's imported rows disappeared, while Personal remained. The fixture's upstream account state was byte-for-byte unchanged. No upstream remove or rename action occurred. | +| Search and pagination during sync | Searched Spotify while checking; cleared search; moved to page 2 showing 25–48 of 1,548 connectors; searched meeting apps while checking. Installed accounts remained above the catalog and browsing stayed responsive. | + +Additional checks: a single-provider Circleback action skipped the provider picker. After Work's removal the saved-account dropdown offered Personal and Connect a new account. Choosing the latter opened normal gateway setup with organization-wide defaults, a Reuse an existing session disclosure, and one Change link. Cancel created no gateway. + +The fixture call log recorded **list** and one **add** action, with a maximum batch size of **32**. Database checks found zero fixture issues, no added agent grants, an archived Work gateway, and an active Personal gateway. The fixture's health check was adjusted to prevent the real server from probing the fictional host; this was fixture setup, not a product fix. After recording these results, the disposable company was archived and its fixture server and browser tab were closed. The live Apps page remains available. + +### Issues found and corrected during the walkthrough + +1. The legacy targeted setup URL opened a new-gateway wizard despite an existing active gateway. It now uses the saved-account chooser, preserving explicit new/resume/reconnect flows. +2. Successful targeted setup left query parameters that reopened the dialog on reload. Completion and cancel now consume that setup query. +3. The imported account button had a duplicated “account account” accessible label. The label now uses the account's actual name. +4. Gateway permission scope appeared below the controls. It now precedes them so users see the scope before editing. +5. The bare Composio dashboard URL opened Platform developer onboarding. The consumer-management link now uses `https://dashboard.composio.dev/~/org/connect/apps`, verified in the embedded browser without embedding a user-specific organization slug. + +### Automated verification + +- Focused UI/shared tests: **320 passed** across Browse, AppsConnect, AppDetail, aggregator setup, and catalog mapping. +- Backend Composio setup/sync tests: **21 passed**, including batch size, concurrent leases, incomplete/error evidence, recovery, company access, and credential changes during a check. +- Repository-wide `pnpm -r typecheck`: passed. +- Repository-wide `pnpm build`: passed; the final management-link edit also passed a fresh UI build. +- `pnpm check:token-gates`: passed after the final UI edit. +- Repository-wide `pnpm test:run`: failed in the first general-server phase after **14,825 tests passed**, with timeout failures in access-service startup, project-icon startup, direct-adapter execution, and Git streaming, plus a worker-start timeout for server-info. Later workspace/serialized phases did not run because this command stops on failure. +- Separate rerun of those five files: **32 tests passed** across access-service, project-icon-persistence, heartbeat-direct-adapter-native-isolation, and server-info. The single `workspace-git-snapshot-streaming.test.ts` test still timed out at its 300,000 ms limit. That test and its implementation were not changed by this work. No full-suite pass is claimed; its remaining timeout is recorded rather than hidden or worked around by changing the test. + +### Coverage limits + +Automatic discovery covers the supported public catalog, not arbitrary custom apps absent from that catalog. It uses the authenticated Connect MCP account-list tool because saved gateway OAuth is not a consumer list-all API key. Observations are isolated by company, human viewer, and credential selection; changed credentials require fresh evidence. + +The hosted authorization handoff, premature completion, and post-authorization verification were tested with controlled provider responses. This report does not claim that a new real Spotify OAuth consent flow was completed. No paid runner evaluation, exhaustive mobile/accessibility audit, or new Arcade account synchronization was performed. Existing Arcade setup remains intact. + +Paperclip still exposes and governs the saved gateway's outer MCP tool catalog. Imported app rows do not re-publish each underlying service's tools or promise app-level permission isolation inside Composio's gateway. diff --git a/doc/plans/2026-10-05-managed-aggregator-account-user-stories.md b/doc/plans/2026-10-05-managed-aggregator-account-user-stories.md new file mode 100644 index 0000000000..8fa2392010 --- /dev/null +++ b/doc/plans/2026-10-05-managed-aggregator-account-user-stories.md @@ -0,0 +1,86 @@ +# Managed aggregator accounts — acceptance stories + +Date: 2026-10-05 + +## Expected behavior + +1. **Discover existing accounts.** After saving an Arcade or Executor gateway, opening Apps discovers the accounts visible through that gateway. No individual app reconnection, task, extra executable connection, or access grant is required. +2. **Group accounts.** Notion appears once with its native account, a Composio account, two Arcade accounts, and an Executor account. Identical labels remain distinct by provider, gateway, and upstream account identity. Native Connect/Add account remains the onboarding action. +3. **Reconcile upstream changes.** Add or disconnect an upstream account, then use Refresh in only that gateway’s menu. A successful scan updates its accounts; other gateways’ observations remain intact. +4. **Manage upstream.** Imported account menus contain only Open in the provider. Links are HTTPS and contain no credentials. Executor workspace/self-hosted paths remain intact. Upstream deletion and per-app authorization stay upstream; Paperclip policies apply to the executable parent gateway. +5. **Optional Arcade sync.** A gateway using a project key and Arcade user ID reuses those credentials. Otherwise Set up account sync asks for an optional project key and user ID. Its vault credential is used for discovery only; gateway tool invocation still uses its existing credentials. +6. **Honest discovery states.** Unsupported inventory, expired sign-in, partial pages, and stale observations never become successful empty inventories or green checks. Failed scans retain previous accounts and invite refresh/setup. +7. **Isolation and rotation.** Account observations are scoped to company, saved gateway, viewing human, and credential version. Rotating a credential or changing the session endpoint hides prior observations until a successful scan. Account discovery cannot grant access. +8. **Navigate five chips.** Paperclip defaults to native discovery with all installed apps above it. Composio/Arcade scope discovery while retaining all connected account lines on grouped cards. Native cards appear under a provider only if that provider has observed accounts for the app, not merely because its catalog supports the app. Installed shows only installed apps; All includes every source. Executor's chip is temporarily hidden; its gateway and observed accounts remain available. A Paperclip search expands to All and clearing returns to Paperclip; explicit filters stay scoped. Pagination affects discovery only. +9. **Unknown integrations and small screens.** Unknown Executor integrations receive a separate card without guessed branding. Empty results remain actionable. Chips wrap and account rows remain readable at mobile widths. +10. **Inspect a saved gateway.** Composio setup opens its Permissions page. Opening that page starts one refresh, displays progress, and shows detected apps with their logos in a bounded, keyboard-scrollable list. Refresh Composio checks only this saved gateway. Partial failures retain observations as unverified. Account discovery does not change human/agent permissions. The redundant OAuth scope guidance and “Reconnect to update permissions” button are removed; actual authorization-repair controls remain available. + +## Reproduction + +Run this checkout’s Storybook on port 6200 and the disposable acceptance server: + +```sh +node cli/node_modules/tsx/dist/cli.mjs tests/aggregator-accounts/test-drive.ts +``` + +Open `http://localhost:6200/iframe.html?id=apps-managed-accounts--full-stack-test-drive&viewMode=story`. + +This mounts the production Apps page against production tool-access routes and a disposable PostgreSQL database. Only external provider responses are simulated. The upstream controls change fixture inventory; use the gateway menu to refresh and observe the result. Existing app data and other local services are untouched. + +The catalog-only stories remain at `Apps/Catalog source selection` and demonstrate the five current chips, pagination, native onboarding precedence, and grouped accounts. + +## Evidence and limitations + +Verified in the embedded browser on 2026-10-05 against the production Apps component, production routes, and disposable PostgreSQL. External Composio, Arcade, and Executor responses were simulated. + +| Story | Result and evidence | +| --- | --- | +| Existing accounts | Passed: saved gateways imported existing accounts without app reconnection. Gateway onboarding tests confirm Arcade returns to Apps without an agent task. | +| Grouping | Passed: one Notion card showed a native account, a Composio account, two accounts from each of two Arcade gateways, and an Executor account. Repeated “Work” labels remained separate. | +| Upstream changes | Passed: adding and disconnecting simulated Arcade accounts followed by its gateway’s Refresh updated only that gateway’s account rows. Other providers and the native account remained. | +| Upstream management | Passed: imported kebabs contained only Open in the provider. Executor’s link retained its workspace path. Browser verification inspected destinations; it did not complete external provider sign-in. | +| Optional Arcade sync | Passed: the browser saved a synthetic discovery key through the real manager-only endpoint and imported the second gateway’s accounts. Database tests also prove existing API-key/user-header reuse and discovery-only vault use. | +| Failure states | Passed: expired authorization and an incomplete page retained last-known accounts without green checks. Unsupported Executor inventory showed unavailable, retained stale accounts, and recovered after refresh. | +| Isolation and rotation | Passed: service tests cover viewing-user/gateway/company isolation, member/agent configuration denial, endpoint changes, and an actual vault credential rotation followed by discovery using the new value. UI/API-client tests prove user switches do not reuse another user’s account cache or pending HTTP response. Manager account responses also use private, no-store HTTP caching. | +| Navigation | Passed: all six chips, Paperclip-to-All search expansion, explicit source scope, clear search, installed pinning, pagination, and empty states. The catalog Storybook interaction also completed without console errors. | +| Unknown apps and mobile | Passed: an unknown Executor integration retained a separate card and unverified status. At 390px the production page’s content width was 382px, with wrapped chips and readable account rows. The viewport override was reset afterwards. | + +The initial fixture run ended on the full-stack story. Its upstream controls are explicitly labeled simulated. That run did not exercise native OAuth or live provider dashboards; working Arcade/Executor credentials were unavailable. + +### Permissions-page follow-up: real Paperclip test drive + +The actual CLI `test-drive` server is running from this checkout at +`http://localhost:3105`, with an isolated PostgreSQL database and the agent key +from `~/.secrets`. The user subsequently connected a real Composio gateway. +The embedded browser verified its Permissions page at +`/AGG/apps/f878eeb0-b1b9-4081-b779-ffecf3b41e7a/permissions`: + +- Detected Airtable and Circleback from the live saved Composio session. +- Manual Refresh Composio displayed “Refreshing…” and catalog-check progress, + then completed with both apps visible. +- Navigating away and returning automatically started a refresh without a click. +- The redundant sign-in guidance and update-permissions button are absent. +- The catalog exposes Paperclip, Composio, Arcade, Installed, and All. +- 317 focused AppDetail, AppsConnect, and Browse tests pass, including setup + navigation, first-load refresh, progress/completion, upstream reconciliation, + retained observations after failure, unsupported discovery, and manager-only + visibility. UI typecheck, UI production build, and token gates pass. + +This walkthrough did not repeat OAuth sign-in or change upstream accounts. The +post-setup redirect is covered by connection-flow tests. The real gateway had two +observed apps, so overflow with a long real account inventory was not exercised. +The list has a bounded height and keyboard-focusable vertical scroll container. +Refresh and settled-page screenshots are saved as `composio-apps-refreshing.png` +and `composio-permissions-connected-apps.png` in this task's visualization folder. + +### Automated checks + +- 261 focused UI/provider/API-client tests passed: Browse, AppsConnect, inventory adapters, and HTTP request isolation. +- 8 database/service/authorization tests passed, with no skips in the final run. These reused the disposable browser-test database because the host had exhausted its 32 PostgreSQL shared-memory slots. The optional `PAPERCLIP_AGGREGATOR_TEST_DATABASE_URL` test override accepts only a local, disposable database. +- 9 shared catalog tests passed. +- Final `pnpm -r typecheck`, `pnpm build`, token gates, and `git diff --check` passed. +- `pnpm test:run` was attempted but did not complete successfully. It encountered a timeout in `workspace-git-snapshot-streaming.test.ts`, failures in `heartbeat-run-event-sequencing.test.ts`, and numerous PostgreSQL startup skips; the run ended with exit 130. A separate remote-MCP compatibility run also could not start PostgreSQL. These repository-wide checks are not reported as green. + +Local verification logs: `/tmp/paperclip-aggregator-focused-final.log`, `/tmp/paperclip-aggregator-db-final.log`, `/tmp/paperclip-full-typecheck-final.log`, `/tmp/paperclip-full-build-final.log`, `/tmp/paperclip-token-gates-final.log`, and `/tmp/paperclip-full-test.log`. + +Desktop and mobile screenshots are saved in the task’s visualization workspace as `managed-aggregator-accounts.png` and `managed-aggregator-accounts-mobile.png`. This is fixture-backed acceptance evidence, not live provider certification or a production release sign-off. diff --git a/packages/db/src/connections-v3-schema-core-migration.test.ts b/packages/db/src/connections-v3-schema-core-migration.test.ts index a1ee50090c..4072d63c42 100644 --- a/packages/db/src/connections-v3-schema-core-migration.test.ts +++ b/packages/db/src/connections-v3-schema-core-migration.test.ts @@ -47,6 +47,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => { await sql`DROP TABLE IF EXISTS "browser_use_runs"`; await sql`DROP TABLE IF EXISTS "browser_use_sessions"`; await sql`DROP TABLE IF EXISTS "browser_use_settings"`; + // Managed-account observations are later consumers of the same connection key. + await sql`DROP TABLE IF EXISTS "tool_connection_app_syncs"`; + await sql`DROP TABLE IF EXISTS "tool_connection_app_snapshots"`; await sql`DROP TABLE IF EXISTS "connection_grants"`; await sql`DROP INDEX IF EXISTS "tool_connections_company_uid_uq"`; await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_company_id_uq"`; diff --git a/packages/db/src/migrations/0295_public_captain_cross.sql b/packages/db/src/migrations/0295_public_captain_cross.sql new file mode 100644 index 0000000000..aa22d7d934 --- /dev/null +++ b/packages/db/src/migrations/0295_public_captain_cross.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "tool_connection_app_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "user_id" text NOT NULL, + "credential_key" text NOT NULL, + "toolkit" text NOT NULL, + "status" text NOT NULL, + "accounts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "checked_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tool_connection_app_snapshots" ADD CONSTRAINT "tool_connection_app_snapshots_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tool_connection_app_snapshots" ADD CONSTRAINT "tool_connection_app_snapshots_company_connection_fk" FOREIGN KEY ("company_id","connection_id") REFERENCES "public"."tool_connections"("company_id","id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_connection_app_snapshots_owner_toolkit_uq" ON "tool_connection_app_snapshots" USING btree ("company_id","connection_id","user_id","toolkit"); diff --git a/packages/db/src/migrations/0296_stiff_thaddeus_ross.sql b/packages/db/src/migrations/0296_stiff_thaddeus_ross.sql new file mode 100644 index 0000000000..7150d5bd9c --- /dev/null +++ b/packages/db/src/migrations/0296_stiff_thaddeus_ross.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS "tool_connection_app_syncs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "connection_id" uuid NOT NULL, + "user_id" text NOT NULL, + "credential_key" text NOT NULL, + "lease_id" uuid NOT NULL, + "status" text NOT NULL, + "checked" integer DEFAULT 0 NOT NULL, + "total" integer DEFAULT 0 NOT NULL, + "failed" integer DEFAULT 0 NOT NULL, + "last_completed_at" timestamp with time zone, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "tool_connection_app_snapshots" ADD COLUMN IF NOT EXISTS "error_at" timestamp with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tool_connection_app_syncs" ADD CONSTRAINT "tool_connection_app_syncs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "tool_connection_app_syncs" ADD CONSTRAINT "tool_connection_app_syncs_company_connection_fk" FOREIGN KEY ("company_id","connection_id") REFERENCES "public"."tool_connections"("company_id","id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "tool_connection_app_syncs_owner_key_uq" ON "tool_connection_app_syncs" USING btree ("company_id","connection_id","user_id","credential_key"); diff --git a/packages/db/src/migrations/meta/0291_snapshot.json b/packages/db/src/migrations/meta/0295_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0291_snapshot.json rename to packages/db/src/migrations/meta/0295_snapshot.json index 22572d546d..13a13294ad 100644 --- a/packages/db/src/migrations/meta/0291_snapshot.json +++ b/packages/db/src/migrations/meta/0295_snapshot.json @@ -1,6 +1,6 @@ { - "id": "a02dd1cf-75c9-4c4d-b13d-cf5dcd729ad3", - "prevId": "26315a87-4675-4dea-9634-efe5e878cc7d", + "id": "02d31b6c-7df3-4f6c-b906-e2d369b5e1cd", + "prevId": "66a0b3c9-91bb-46b8-a868-a6facedbc14b", "version": "7", "dialect": "postgresql", "tables": { @@ -12778,6 +12778,12 @@ "primaryKey": false, "notNull": false }, + "inspection": { + "name": "inspection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "skill_id": { "name": "skill_id", "type": "uuid", @@ -31459,6 +31465,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", @@ -44484,6 +44497,139 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.tool_connection_app_snapshots": { + "name": "tool_connection_app_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accounts": { + "name": "accounts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_connection_app_snapshots_owner_toolkit_uq": { + "name": "tool_connection_app_snapshots_owner_toolkit_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "toolkit", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_snapshots_company_id_companies_id_fk": { + "name": "tool_connection_app_snapshots_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_snapshots_company_connection_fk": { + "name": "tool_connection_app_snapshots_company_connection_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.tool_connection_installs": { "name": "tool_connection_installs", "schema": "", @@ -46196,6 +46342,27 @@ "method": "btree", "with": {} }, + "tool_mcp_gateway_tokens_expiry_idx": { + "name": "tool_mcp_gateway_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/0290_snapshot.json b/packages/db/src/migrations/meta/0296_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0290_snapshot.json rename to packages/db/src/migrations/meta/0296_snapshot.json index 63ef4c6fdd..ae9187765c 100644 --- a/packages/db/src/migrations/meta/0290_snapshot.json +++ b/packages/db/src/migrations/meta/0296_snapshot.json @@ -1,6 +1,6 @@ { - "id": "26315a87-4675-4dea-9634-efe5e878cc7d", - "prevId": "3960ae6b-5bf1-4fd7-91db-6a18fc7eaa19", + "id": "a6c67322-9605-4234-b05d-b3585e943e42", + "prevId": "02d31b6c-7df3-4f6c-b906-e2d369b5e1cd", "version": "7", "dialect": "postgresql", "tables": { @@ -12737,6 +12737,373 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_source_entries": { + "name": "company_skill_source_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inspection": { + "name": "inspection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "present": { + "name": "present", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_skill_source_entries_source_path_idx": { + "name": "company_skill_source_entries_source_path_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_source_entries_company_skill_idx": { + "name": "company_skill_source_entries_company_skill_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_source_entries_company_id_companies_id_fk": { + "name": "company_skill_source_entries_company_id_companies_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_source_id_company_skill_sources_id_fk": { + "name": "company_skill_source_entries_source_id_company_skill_sources_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skill_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_source_entries_skill_id_company_skills_id_fk": { + "name": "company_skill_source_entries_skill_id_company_skills_id_fk", + "tableFrom": "company_skill_source_entries", + "tableTo": "company_skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skill_sources": { + "name": "company_skill_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_ref": { + "name": "tracking_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "excluded_folders": { + "name": "excluded_folders", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scan_commit": { + "name": "last_scan_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_sources_repository_ref_idx": { + "name": "company_skill_sources_repository_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skill_sources_identity_ref_idx": { + "name": "company_skill_sources_identity_ref_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_sources_company_id_companies_id_fk": { + "name": "company_skill_sources_company_id_companies_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_skill_sources_connection_id_tool_connections_id_fk": { + "name": "company_skill_sources_connection_id_tool_connections_id_fk", + "tableFrom": "company_skill_sources", + "tableTo": "tool_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_comments": { "name": "company_skill_comments", "schema": "", @@ -31098,6 +31465,13 @@ "primaryKey": false, "notNull": true }, + "title_needs_generation": { + "name": "title_needs_generation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "description": { "name": "description", "type": "text", @@ -44123,6 +44497,298 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.tool_connection_app_snapshots": { + "name": "tool_connection_app_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accounts": { + "name": "accounts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_at": { + "name": "error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tool_connection_app_snapshots_owner_toolkit_uq": { + "name": "tool_connection_app_snapshots_owner_toolkit_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "toolkit", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_snapshots_company_id_companies_id_fk": { + "name": "tool_connection_app_snapshots_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_snapshots_company_connection_fk": { + "name": "tool_connection_app_snapshots_company_connection_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_connection_app_syncs": { + "name": "tool_connection_app_syncs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_completed_at": { + "name": "last_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_connection_app_syncs_owner_key_uq": { + "name": "tool_connection_app_syncs_owner_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_syncs_company_id_companies_id_fk": { + "name": "tool_connection_app_syncs_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_syncs_company_connection_fk": { + "name": "tool_connection_app_syncs_company_connection_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.tool_connection_installs": { "name": "tool_connection_installs", "schema": "", @@ -45835,6 +46501,27 @@ "method": "btree", "with": {} }, + "tool_mcp_gateway_tokens_expiry_idx": { + "name": "tool_mcp_gateway_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 15430d3556..9c603bcb3b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2052,6 +2052,20 @@ "when": 1790891286527, "tag": "0294_chilly_marvel_apes", "breakpoints": true + }, + { + "idx": 295, + "version": "7", + "when": 1790994114760, + "tag": "0295_public_captain_cross", + "breakpoints": true + }, + { + "idx": 296, + "version": "7", + "when": 1791028244412, + "tag": "0296_stiff_thaddeus_ross", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 19df923590..75116399cc 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -218,3 +218,5 @@ export { browserUseSettings, browserUseSessions, browserUseRuns, browserUseBrows export * from "./company_skill_sources.js"; +export { toolConnectionAppSnapshots } from "./tool_connection_app_snapshots.js"; +export { toolConnectionAppSyncs } from "./tool_connection_app_syncs.js"; diff --git a/packages/db/src/schema/tool_connection_app_snapshots.ts b/packages/db/src/schema/tool_connection_app_snapshots.ts new file mode 100644 index 0000000000..3d019c90af --- /dev/null +++ b/packages/db/src/schema/tool_connection_app_snapshots.ts @@ -0,0 +1,21 @@ +import { foreignKey, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import type { ComposioAppAccount } from "@paperclipai/shared"; +import { companies } from "./companies.js"; +import { toolConnections } from "./tool_access.js"; + +/** Non-secret upstream account observations, scoped to the viewing human's credential. */ +export const toolConnectionAppSnapshots = pgTable("tool_connection_app_snapshots", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull(), + userId: text("user_id").notNull(), + credentialKey: text("credential_key").notNull(), + toolkit: text("toolkit").notNull(), + status: text("status").$type<"connected" | "not_connected">().notNull(), + accounts: jsonb("accounts").$type().notNull().default([]), + checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(), + errorAt: timestamp("error_at", { withTimezone: true }), +}, t => [ + uniqueIndex("tool_connection_app_snapshots_owner_toolkit_uq").on(t.companyId, t.connectionId, t.userId, t.toolkit), + foreignKey({ columns: [t.companyId, t.connectionId], foreignColumns: [toolConnections.companyId, toolConnections.id], name: "tool_connection_app_snapshots_company_connection_fk" }).onDelete("cascade"), +]); diff --git a/packages/db/src/schema/tool_connection_app_syncs.ts b/packages/db/src/schema/tool_connection_app_syncs.ts new file mode 100644 index 0000000000..823721a04b --- /dev/null +++ b/packages/db/src/schema/tool_connection_app_syncs.ts @@ -0,0 +1,22 @@ +import { foreignKey, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { toolConnections } from "./tool_access.js"; + +/** One bounded discovery lease per viewing human and saved credential identity. */ +export const toolConnectionAppSyncs = pgTable("tool_connection_app_syncs", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + connectionId: uuid("connection_id").notNull(), + userId: text("user_id").notNull(), + credentialKey: text("credential_key").notNull(), + leaseId: uuid("lease_id").notNull(), + status: text("status").$type<"syncing" | "ready" | "error" | "unsupported">().notNull(), + checked: integer("checked").notNull().default(0), + total: integer("total").notNull().default(0), + failed: integer("failed").notNull().default(0), + lastCompletedAt: timestamp("last_completed_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, table => [ + uniqueIndex("tool_connection_app_syncs_owner_key_uq").on(table.companyId, table.connectionId, table.userId, table.credentialKey), + foreignKey({ columns: [table.companyId, table.connectionId], foreignColumns: [toolConnections.companyId, toolConnections.id], name: "tool_connection_app_syncs_company_connection_fk" }).onDelete("cascade"), +]); diff --git a/packages/shared/src/aggregator-app-catalog.test.ts b/packages/shared/src/aggregator-app-catalog.test.ts new file mode 100644 index 0000000000..39ee377e55 --- /dev/null +++ b/packages/shared/src/aggregator-app-catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { AGGREGATOR_APP_CATALOG, COMPOSIO_APP_TOOLKITS, findComposioCatalogApp, findAggregatorApp } from "./aggregator-app-catalog.js"; + +describe("public aggregator app catalog", () => { + it("indexes the full snapshots and merges provider routes for the same app", () => { + expect(AGGREGATOR_APP_CATALOG.length).toBeGreaterThan(1500); + const hubspot = findAggregatorApp("composio", "hubspot")!; + expect(hubspot.routes.map((route) => route.provider)).toEqual(["composio", "arcade"]); + expect(findAggregatorApp("arcade", "hubspot")).toBe(hubspot); + expect(findAggregatorApp("composio", "made-up-toolkit")).toBeUndefined(); + expect(findAggregatorApp("executor", "hubspot")).toBeUndefined(); + }); + + it("retains every upstream toolkit variant for discovery and maps it to an app card", () => { + expect(COMPOSIO_APP_TOOLKITS.length).toBeGreaterThan(1500); + expect(COMPOSIO_APP_TOOLKITS.every(toolkit => findComposioCatalogApp(toolkit))).toBe(true); + expect(findComposioCatalogApp("circleback_mcp")?.name).toBe("Circleback"); + expect(findComposioCatalogApp("notion")?.name).toBe("Notion"); + expect(findComposioCatalogApp("made-up-toolkit")).toBeUndefined(); + }); + + it("keeps real provider toolkit identifiers and public logo/evidence URLs", () => { + expect(findAggregatorApp("composio", "active_campaign")?.routes[0]).toMatchObject({ + toolkit: "active_campaign", logoUrl: "https://logos.composio.dev/api/active_campaign", + }); + for (const app of AGGREGATOR_APP_CATALOG) { + expect(app.name).toBeTruthy(); + expect(new Set(app.routes.map((route) => route.provider)).size).toBe(app.routes.length); + for (const route of app.routes) { + expect(new URL(route.logoUrl).protocol).toBe("https:"); + expect(new URL(route.docsUrl).hostname).toBe(`docs.${route.provider}.dev`); + } + } + }); + + it.each([ + ["close", "closeio"], + ["excel", "microsoft-excel"], + ["one_drive", "microsoft-onedrive"], + ["share_point", "microsoft-sharepoint"], + ["square", "squareup-api"], + ["twitter", "x"], + ])("merges the same service despite different provider branding (%s)", (composioToolkit, arcadeToolkit) => { + const app = findAggregatorApp("composio", composioToolkit)!; + expect(findAggregatorApp("arcade", arcadeToolkit)).toBe(app); + expect(app.routes.map((route) => route.provider)).toEqual(["composio", "arcade"]); + }); +}); diff --git a/packages/shared/src/aggregator-app-catalog.ts b/packages/shared/src/aggregator-app-catalog.ts new file mode 100644 index 0000000000..d6778b07a4 --- /dev/null +++ b/packages/shared/src/aggregator-app-catalog.ts @@ -0,0 +1,95 @@ +import composio from "./composio-search-catalog.json" with { type: "json" }; +import arcade from "./arcade-app-catalog.json" with { type: "json" }; + +export type AppCatalogAggregator = "composio" | "arcade" | "executor"; + +export interface AggregatorAppRoute { + provider: AppCatalogAggregator; + toolkit: string; + logoUrl: string; + docsUrl: string; +} + +export interface AggregatorAppCatalogEntry { + slug: string; + name: string; + aliases: string[]; + routes: AggregatorAppRoute[]; +} + +// The public catalogs use different brand names for these same services. +const SERVICE_IDENTITY_ALIASES = new Map([ + ["closeio", "close"], + ["microsoftexcel", "excel"], + ["microsoftonedrive", "onedrive"], + ["microsoftsharepoint", "sharepoint"], + ["squareup", "square"], + ["x", "twitter"], +]); + +/** Public discovery metadata only. A listing does not establish app authorization. */ +export function aggregatorAppIdentity(value: string): string { + const identity = value.toLowerCase().replace(/\s+(mcp|api)$/i, "") + .replace(/[^a-z0-9]/g, ""); + return SERVICE_IDENTITY_ALIASES.get(identity) ?? identity; +} + +const entries = new Map(); +function add(name: string, slug: string, aliases: string[], route: AggregatorAppRoute) { + const identity = aggregatorAppIdentity(name); + const existing = entries.get(identity); + if (existing) { + existing.aliases = [...new Set([...existing.aliases, name, slug, ...aliases])]; + // Prefer the ordinary toolkit over an API/MCP variant from the same provider. + if (!existing.routes.some((candidate) => candidate.provider === route.provider)) { + existing.routes.push(route); + } + return; + } + entries.set(identity, { slug, name: name.replace(/\s+(MCP|API)$/i, ""), aliases, routes: [route] }); +} + +for (const [toolkit, name] of composio.toolkits) { + add(name, toolkit.replaceAll("_", "-").replace(/^-+/, ""), [toolkit], { + provider: "composio", toolkit, + logoUrl: `https://logos.composio.dev/api/${toolkit}`, + docsUrl: `https://docs.composio.dev/toolkits/${toolkit}`, + }); +} +for (const app of arcade.apps) { + add(app.name, app.slug, app.aliases, { + provider: "arcade", toolkit: app.toolkit, logoUrl: app.logoUrl, docsUrl: app.docsUrl, + }); +} + +export const AGGREGATOR_APP_CATALOG = [...entries.values()].sort((a, b) => + a.name.localeCompare(b.name, "en", { sensitivity: "base" }) || a.slug.localeCompare(b.slug)); + +/** Include provider variants and native overlaps when checking existing accounts. */ +export const COMPOSIO_APP_TOOLKITS = composio.toolkits.map(([toolkit]) => toolkit); + +export function findComposioCatalogApp(toolkit: string) { + return AGGREGATOR_APP_CATALOG.find(app => app.routes.some(route => route.provider === "composio") + && (app.aliases.includes(toolkit) || app.routes.some(route => route.provider === "composio" && route.toolkit === toolkit))); +} + +export function findAggregatorApp(provider: string, toolkit: string | null | undefined) { + if (!toolkit) return undefined; + return AGGREGATOR_APP_CATALOG.find((app) => + app.routes.some((route) => route.provider === provider) + && (app.routes.some(route => route.provider === provider && route.toolkit === toolkit) || app.aliases.includes(toolkit))); +} + +/** Executor supports custom integrations. Only exact known service identities share branding. */ +export function resolveAggregatorApp(provider: AppCatalogAggregator, toolkit: string, name?: string) { + const known = provider === "composio" ? findComposioCatalogApp(toolkit) : findAggregatorApp(provider, toolkit) + ?? AGGREGATOR_APP_CATALOG.find(app => app.routes.some(route => route.provider === provider) + && [app.slug, ...app.aliases].some(alias => aggregatorAppIdentity(alias) === aggregatorAppIdentity(toolkit))); + if (known) return { slug: known.slug, name: known.name }; + if (provider === "executor") { + const identity = aggregatorAppIdentity(toolkit); + const match = AGGREGATOR_APP_CATALOG.find(app => [app.slug, ...app.aliases].some(alias => aggregatorAppIdentity(alias) === identity)); + if (match) return { slug: match.slug, name: match.name }; + } + return { slug: `${provider}:${toolkit}`, name: name || toolkit }; +} diff --git a/packages/shared/src/aggregator-apps.ts b/packages/shared/src/aggregator-apps.ts new file mode 100644 index 0000000000..2a69a54ee0 --- /dev/null +++ b/packages/shared/src/aggregator-apps.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import type { ComposioAppSnapshot, ComposioAppSyncState } from "./composio-app-setup.js"; +import type { AppCatalogAggregator } from "./aggregator-app-catalog.js"; + +export const AGGREGATOR_NAMES = { composio: "Composio", arcade: "Arcade", executor: "Executor" } as const; +export function isAppAggregator(value: unknown): value is AppCatalogAggregator { + return typeof value === "string" && Object.hasOwn(AGGREGATOR_NAMES, value); +} + +/** Cached upstream observations. These neither grant access nor create tool connections. */ +export interface AggregatorAppSnapshot extends ComposioAppSnapshot { + provider: AppCatalogAggregator; + appSlug: string; + appName: string; + freshness: "fresh" | "stale"; +} +export interface AggregatorAppsResponse { + provider: AppCatalogAggregator; + apps: AggregatorAppSnapshot[]; + discovery: { + availability: "available" | "setup_required" | "unsupported" | "disabled"; + message: string | null; + }; + sync: Omit & { + coverage: "supported_catalog" | "gateway_tools" | "visible_accounts"; + }; +} +export const aggregatorAppsSyncSchema = z.object({ force: z.boolean().default(false) }).strict(); +export const aggregatorAppsRefreshSchema = z.object({ + toolkits: z.array(z.string().min(1).max(200)).max(64).default([]), +}).strict(); +export const arcadeDiscoverySetupSchema = z.object({ + apiKey: z.string().trim().min(1).max(8192), + userId: z.string().trim().min(1).max(500), +}).strict(); +export type ArcadeDiscoverySetupInput = z.infer; + +/** Only trustworthy browser destinations; never expose URL credentials or active schemes. */ +export function aggregatorManagementUrl(provider: AppCatalogAggregator, configured?: string | null): string | null { + if (configured) { + try { + const url = new URL(configured); + if (url.protocol !== "https:" || url.username || url.password || [...url.searchParams.keys()].some(key => /token|secret|api.?key|authorization|code/i.test(key))) return null; + if (provider === "composio" && url.hostname !== "dashboard.composio.dev") return null; + if (provider === "arcade" && url.hostname !== "app.arcade.dev") return null; + return url.toString(); + } catch { return null; } + } + return provider === "composio" ? "https://dashboard.composio.dev/~/org/connect/apps" + : provider === "arcade" ? "https://app.arcade.dev/" : null; +} diff --git a/packages/shared/src/arcade-app-catalog.json b/packages/shared/src/arcade-app-catalog.json new file mode 100644 index 0000000000..d0b5221ace --- /dev/null +++ b/packages/shared/src/arcade-app-catalog.json @@ -0,0 +1,1602 @@ +{ + "source": "https://docs.arcade.dev/en/resources/integrations", + "verifiedAt": "2026-10-02", + "apps": [ + { + "slug": "airtable", + "toolkit": "airtable", + "name": "Airtable", + "logoUrl": "https://design-system.arcade.dev/icons/airtable.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/airtable", + "aliases": [ + "Airtable", + "airtable" + ], + "category": "productivity" + }, + { + "slug": "airtable-api", + "toolkit": "airtable-api", + "name": "Airtable API", + "logoUrl": "https://design-system.arcade.dev/icons/airtable.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/airtable-api", + "aliases": [ + "AirtableApi", + "airtable-api" + ], + "category": "productivity" + }, + { + "slug": "apollo", + "toolkit": "apollo", + "name": "Apollo", + "logoUrl": "https://design-system.arcade.dev/icons/apollo.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/apollo", + "aliases": [ + "Apollo", + "apollo" + ], + "category": "sales" + }, + { + "slug": "arcade-engine-api", + "toolkit": "arcade-engine-api", + "name": "Arcade Engine API", + "logoUrl": "https://design-system.arcade.dev/icons/arcade.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "ArcadeEngineApi", + "arcade-engine-api" + ], + "category": "development" + }, + { + "slug": "asana", + "toolkit": "asana", + "name": "Asana", + "logoUrl": "https://design-system.arcade.dev/icons/asana.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/asana", + "aliases": [ + "Asana", + "asana" + ], + "category": "productivity" + }, + { + "slug": "asana-api", + "toolkit": "asana-api", + "name": "Asana API", + "logoUrl": "https://design-system.arcade.dev/icons/asana.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/asana-api", + "aliases": [ + "AsanaApi", + "asana-api" + ], + "category": "productivity" + }, + { + "slug": "ashby", + "toolkit": "ashby", + "name": "Ashby", + "logoUrl": "https://design-system.arcade.dev/icons/ashby.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/ashby", + "aliases": [ + "Ashby", + "ashby" + ], + "category": "productivity" + }, + { + "slug": "ashby-api", + "toolkit": "ashby-api", + "name": "Ashby API", + "logoUrl": "https://design-system.arcade.dev/icons/ashby.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/ashby-api", + "aliases": [ + "AshbyApi", + "ashby-api" + ], + "category": "productivity" + }, + { + "slug": "attio", + "toolkit": "attio", + "name": "Attio", + "logoUrl": "https://design-system.arcade.dev/icons/attio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/attio", + "aliases": [ + "Attio", + "attio" + ], + "category": "sales" + }, + { + "slug": "box-api", + "toolkit": "box-api", + "name": "Box API", + "logoUrl": "https://design-system.arcade.dev/icons/box.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/box-api", + "aliases": [ + "BoxApi", + "box-api" + ], + "category": "productivity" + }, + { + "slug": "brightdata", + "toolkit": "brightdata", + "name": "Bright Data", + "logoUrl": "https://design-system.arcade.dev/icons/brightdata.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/brightdata", + "aliases": [ + "Brightdata", + "brightdata" + ], + "category": "development" + }, + { + "slug": "calendly", + "toolkit": "calendly", + "name": "Calendly", + "logoUrl": "https://design-system.arcade.dev/icons/calendly.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/calendly", + "aliases": [ + "Calendly", + "calendly" + ], + "category": "productivity" + }, + { + "slug": "calendly-api", + "toolkit": "calendly-api", + "name": "Calendly API", + "logoUrl": "https://design-system.arcade.dev/icons/calendly.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/calendly-api", + "aliases": [ + "CalendlyApi", + "calendly-api" + ], + "category": "productivity" + }, + { + "slug": "clickhouse", + "toolkit": "clickhouse", + "name": "Clickhouse", + "logoUrl": "https://design-system.arcade.dev/icons/clickhouse.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/clickhouse", + "aliases": [ + "Clickhouse", + "clickhouse" + ], + "category": "databases" + }, + { + "slug": "clickup", + "toolkit": "clickup", + "name": "ClickUp", + "logoUrl": "https://design-system.arcade.dev/icons/clickup.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/clickup", + "aliases": [ + "ClickUp", + "clickup" + ], + "category": "productivity" + }, + { + "slug": "clickup-api", + "toolkit": "clickup-api", + "name": "ClickUp API", + "logoUrl": "https://design-system.arcade.dev/icons/clickup.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/clickup-api", + "aliases": [ + "ClickUpApi", + "clickup-api" + ], + "category": "productivity" + }, + { + "slug": "closeio", + "toolkit": "closeio", + "name": "Close.io", + "logoUrl": "https://design-system.arcade.dev/icons/closeio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "CloseIO", + "closeio" + ], + "category": "productivity" + }, + { + "slug": "confluence", + "toolkit": "confluence", + "name": "Confluence", + "logoUrl": "https://design-system.arcade.dev/icons/confluence.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/confluence", + "aliases": [ + "Confluence", + "confluence" + ], + "category": "productivity" + }, + { + "slug": "cursor-agents", + "toolkit": "cursor-agents", + "name": "Cursor Agents", + "logoUrl": "https://design-system.arcade.dev/icons/cursor.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/cursor-agents", + "aliases": [ + "CursorAgents", + "cursor-agents" + ], + "category": "development" + }, + { + "slug": "cursor-agents-api", + "toolkit": "cursor-agents-api", + "name": "Cursor Agents API", + "logoUrl": "https://design-system.arcade.dev/icons/cursor.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/cursor-agents-api", + "aliases": [ + "CursorAgentsApi", + "cursor-agents-api" + ], + "category": "development" + }, + { + "slug": "customerio", + "toolkit": "customerio", + "name": "Customer.io", + "logoUrl": "https://design-system.arcade.dev/icons/customerio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/customerio", + "aliases": [ + "Customerio", + "customerio" + ], + "category": "customer-support" + }, + { + "slug": "customerio-api", + "toolkit": "customerio-api", + "name": "Customer.io API", + "logoUrl": "https://design-system.arcade.dev/icons/customerio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-api", + "aliases": [ + "CustomerioApi", + "customerio-api" + ], + "category": "customer-support" + }, + { + "slug": "customerio-pipelines-api", + "toolkit": "customerio-pipelines-api", + "name": "Customer.io Pipelines API", + "logoUrl": "https://design-system.arcade.dev/icons/customerio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-pipelines-api", + "aliases": [ + "CustomerioPipelinesApi", + "customerio-pipelines-api" + ], + "category": "customer-support" + }, + { + "slug": "customerio-track-api", + "toolkit": "customerio-track-api", + "name": "Customer.io Track API", + "logoUrl": "https://design-system.arcade.dev/icons/customerio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/customerio-track-api", + "aliases": [ + "CustomerioTrackApi", + "customerio-track-api" + ], + "category": "customer-support" + }, + { + "slug": "datadog", + "toolkit": "datadog", + "name": "Datadog", + "logoUrl": "https://design-system.arcade.dev/icons/datadog.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/datadog", + "aliases": [ + "Datadog", + "datadog" + ], + "category": "development" + }, + { + "slug": "datadog-api", + "toolkit": "datadog-api", + "name": "Datadog API", + "logoUrl": "https://design-system.arcade.dev/icons/datadog.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/datadog-api", + "aliases": [ + "DatadogApi", + "datadog-api" + ], + "category": "development" + }, + { + "slug": "daytona", + "toolkit": "daytona", + "name": "Daytona", + "logoUrl": "https://design-system.arcade.dev/icons/daytona.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/daytona", + "aliases": [ + "Daytona", + "daytona" + ], + "category": "development" + }, + { + "slug": "discord", + "toolkit": "discord", + "name": "Discord", + "logoUrl": "https://design-system.arcade.dev/icons/discord.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "Discord", + "discord" + ], + "category": "social" + }, + { + "slug": "discord-bot", + "toolkit": "discord-bot", + "name": "Discord Bot", + "logoUrl": "https://design-system.arcade.dev/icons/discord.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/discord-bot", + "aliases": [ + "DiscordBot", + "discord-bot" + ], + "category": "social" + }, + { + "slug": "dropbox", + "toolkit": "dropbox", + "name": "Dropbox", + "logoUrl": "https://design-system.arcade.dev/icons/dropbox.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/dropbox", + "aliases": [ + "Dropbox", + "dropbox" + ], + "category": "productivity" + }, + { + "slug": "e2b", + "toolkit": "e2b", + "name": "E2B", + "logoUrl": "https://design-system.arcade.dev/icons/e2b.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/e2b", + "aliases": [ + "E2b", + "e2b" + ], + "category": "development" + }, + { + "slug": "exa-api", + "toolkit": "exa-api", + "name": "Exa API", + "logoUrl": "https://design-system.arcade.dev/icons/exa.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/exa-api", + "aliases": [ + "ExaApi", + "exa-api" + ], + "category": "search" + }, + { + "slug": "figma", + "toolkit": "figma", + "name": "Figma", + "logoUrl": "https://design-system.arcade.dev/icons/figma.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/figma", + "aliases": [ + "Figma", + "figma" + ], + "category": "productivity" + }, + { + "slug": "figma-api", + "toolkit": "figma-api", + "name": "Figma API", + "logoUrl": "https://design-system.arcade.dev/icons/figma.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/figma-api", + "aliases": [ + "FigmaApi", + "figma-api" + ], + "category": "productivity" + }, + { + "slug": "firecrawl", + "toolkit": "firecrawl", + "name": "Firecrawl", + "logoUrl": "https://design-system.arcade.dev/icons/firecrawl.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/firecrawl", + "aliases": [ + "Firecrawl", + "firecrawl" + ], + "category": "development" + }, + { + "slug": "fireflies", + "toolkit": "fireflies", + "name": "Fireflies", + "logoUrl": "https://design-system.arcade.dev/icons/fireflies.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/fireflies", + "aliases": [ + "Fireflies", + "fireflies" + ], + "category": "productivity" + }, + { + "slug": "fly-io", + "toolkit": "fly-io", + "name": "Fly.io", + "logoUrl": "https://design-system.arcade.dev/icons/fly-io.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/fly-io", + "aliases": [ + "FlyIo", + "fly-io" + ], + "category": "development" + }, + { + "slug": "forkable", + "toolkit": "forkable", + "name": "Forkable", + "logoUrl": "https://design-system.arcade.dev/icons/forkable.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/forkable", + "aliases": [ + "Forkable", + "forkable" + ], + "category": "productivity" + }, + { + "slug": "freshdesk", + "toolkit": "freshdesk", + "name": "Freshdesk", + "logoUrl": "https://design-system.arcade.dev/icons/freshdesk.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/freshdesk", + "aliases": [ + "Freshdesk", + "freshdesk" + ], + "category": "customer-support" + }, + { + "slug": "freshservice", + "toolkit": "freshservice", + "name": "Freshservice", + "logoUrl": "https://design-system.arcade.dev/icons/freshservice.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/freshservice", + "aliases": [ + "Freshservice", + "freshservice" + ], + "category": "customer-support" + }, + { + "slug": "freshservice-api", + "toolkit": "freshservice-api", + "name": "Freshservice API", + "logoUrl": "https://design-system.arcade.dev/icons/freshservice.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/freshservice-api", + "aliases": [ + "FreshserviceApi", + "freshservice-api" + ], + "category": "customer-support" + }, + { + "slug": "github", + "toolkit": "github", + "name": "GitHub", + "logoUrl": "https://design-system.arcade.dev/icons/github.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/github", + "aliases": [ + "Github", + "github" + ], + "category": "development" + }, + { + "slug": "github-api", + "toolkit": "github-api", + "name": "GitHub API", + "logoUrl": "https://design-system.arcade.dev/icons/github.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/github-api", + "aliases": [ + "GithubApi", + "github-api" + ], + "category": "development" + }, + { + "slug": "glean", + "toolkit": "glean", + "name": "Glean", + "logoUrl": "https://design-system.arcade.dev/icons/glean.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/glean", + "aliases": [ + "Glean", + "glean" + ], + "category": "search" + }, + { + "slug": "gmail", + "toolkit": "gmail", + "name": "Gmail", + "logoUrl": "https://design-system.arcade.dev/icons/gmail.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/gmail", + "aliases": [ + "Gmail", + "gmail" + ], + "category": "productivity" + }, + { + "slug": "google-calendar", + "toolkit": "google-calendar", + "name": "Google Calendar", + "logoUrl": "https://design-system.arcade.dev/icons/google-calendar.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-calendar", + "aliases": [ + "GoogleCalendar", + "google-calendar" + ], + "category": "productivity" + }, + { + "slug": "google-contacts", + "toolkit": "google-contacts", + "name": "Google Contacts", + "logoUrl": "https://design-system.arcade.dev/icons/google-contacts.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-contacts", + "aliases": [ + "GoogleContacts", + "google-contacts" + ], + "category": "productivity" + }, + { + "slug": "google-docs", + "toolkit": "google-docs", + "name": "Google Docs", + "logoUrl": "https://design-system.arcade.dev/icons/google-docs.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-docs", + "aliases": [ + "GoogleDocs", + "google-docs" + ], + "category": "productivity" + }, + { + "slug": "google-drive", + "toolkit": "google-drive", + "name": "Google Drive", + "logoUrl": "https://design-system.arcade.dev/icons/google-drive.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-drive", + "aliases": [ + "GoogleDrive", + "google-drive" + ], + "category": "productivity" + }, + { + "slug": "google-finance", + "toolkit": "google_finance", + "name": "Google Finance", + "logoUrl": "https://design-system.arcade.dev/icons/google-finance.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_finance", + "aliases": [ + "GoogleFinance", + "google_finance" + ], + "category": "search" + }, + { + "slug": "google-flights", + "toolkit": "google_flights", + "name": "Google Flights", + "logoUrl": "https://design-system.arcade.dev/icons/google-flights.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_flights", + "aliases": [ + "GoogleFlights", + "google_flights" + ], + "category": "search" + }, + { + "slug": "google-forms", + "toolkit": "google-forms", + "name": "Google Forms", + "logoUrl": "https://design-system.arcade.dev/icons/google-forms.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "GoogleForms", + "google-forms" + ], + "category": "productivity" + }, + { + "slug": "google-hotels", + "toolkit": "google_hotels", + "name": "Google Hotels", + "logoUrl": "https://design-system.arcade.dev/icons/google-hotels.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_hotels", + "aliases": [ + "GoogleHotels", + "google_hotels" + ], + "category": "search" + }, + { + "slug": "google-jobs", + "toolkit": "google_jobs", + "name": "Google Jobs", + "logoUrl": "https://design-system.arcade.dev/icons/google-jobs.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_jobs", + "aliases": [ + "GoogleJobs", + "google_jobs" + ], + "category": "search" + }, + { + "slug": "google-maps", + "toolkit": "google_maps", + "name": "Google Maps", + "logoUrl": "https://design-system.arcade.dev/icons/google-maps.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_maps", + "aliases": [ + "GoogleMaps", + "google_maps" + ], + "category": "search" + }, + { + "slug": "google-news", + "toolkit": "google_news", + "name": "Google News", + "logoUrl": "https://design-system.arcade.dev/icons/google-news.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_news", + "aliases": [ + "GoogleNews", + "google_news" + ], + "category": "search" + }, + { + "slug": "google-search", + "toolkit": "google_search", + "name": "Google Search", + "logoUrl": "https://design-system.arcade.dev/icons/google-search.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_search", + "aliases": [ + "GoogleSearch", + "google_search" + ], + "category": "search" + }, + { + "slug": "google-sheets", + "toolkit": "google-sheets", + "name": "Google Sheets", + "logoUrl": "https://design-system.arcade.dev/icons/google-sheets.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-sheets", + "aliases": [ + "GoogleSheets", + "google-sheets" + ], + "category": "productivity" + }, + { + "slug": "google-shopping", + "toolkit": "google_shopping", + "name": "Google Shopping", + "logoUrl": "https://design-system.arcade.dev/icons/google-shopping.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/google_shopping", + "aliases": [ + "GoogleShopping", + "google_shopping" + ], + "category": "search" + }, + { + "slug": "google-slides", + "toolkit": "google-slides", + "name": "Google Slides", + "logoUrl": "https://design-system.arcade.dev/icons/google-slides.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/google-slides", + "aliases": [ + "GoogleSlides", + "google-slides" + ], + "category": "productivity" + }, + { + "slug": "granola", + "toolkit": "granola", + "name": "Granola", + "logoUrl": "https://design-system.arcade.dev/icons/granola.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/granola", + "aliases": [ + "Granola", + "granola" + ], + "category": "productivity" + }, + { + "slug": "hubspot", + "toolkit": "hubspot", + "name": "HubSpot", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot", + "aliases": [ + "Hubspot", + "hubspot" + ], + "category": "sales" + }, + { + "slug": "hubspot-automation-api", + "toolkit": "hubspot-automation-api", + "name": "HubSpot Automation API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-automation-api", + "aliases": [ + "HubspotAutomationApi", + "hubspot-automation-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-cms-api", + "toolkit": "hubspot-cms-api", + "name": "HubSpot CMS API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-cms-api", + "aliases": [ + "HubspotCmsApi", + "hubspot-cms-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-conversations-api", + "toolkit": "hubspot-conversations-api", + "name": "HubSpot Conversations API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-conversations-api", + "aliases": [ + "HubspotConversationsApi", + "hubspot-conversations-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-crm-api", + "toolkit": "hubspot-crm-api", + "name": "HubSpot CRM API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-crm-api", + "aliases": [ + "HubspotCrmApi", + "hubspot-crm-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-events-api", + "toolkit": "hubspot-events-api", + "name": "HubSpot Events API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-events-api", + "aliases": [ + "HubspotEventsApi", + "hubspot-events-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-marketing-api", + "toolkit": "hubspot-marketing-api", + "name": "HubSpot Marketing API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-marketing-api", + "aliases": [ + "HubspotMarketingApi", + "hubspot-marketing-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-meetings-api", + "toolkit": "hubspot-meetings-api", + "name": "HubSpot Meetings API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-meetings-api", + "aliases": [ + "HubspotMeetingsApi", + "hubspot-meetings-api" + ], + "category": "sales" + }, + { + "slug": "hubspot-users-api", + "toolkit": "hubspot-users-api", + "name": "HubSpot Users API", + "logoUrl": "https://design-system.arcade.dev/icons/hubspot.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/hubspot-users-api", + "aliases": [ + "HubspotUsersApi", + "hubspot-users-api" + ], + "category": "sales" + }, + { + "slug": "imgflip", + "toolkit": "imgflip", + "name": "Imgflip", + "logoUrl": "https://design-system.arcade.dev/icons/imgflip.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/entertainment/imgflip", + "aliases": [ + "Imgflip", + "imgflip" + ], + "category": "entertainment" + }, + { + "slug": "insightly", + "toolkit": "insightly", + "name": "Insightly", + "logoUrl": "https://design-system.arcade.dev/icons/insightly.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/insightly", + "aliases": [ + "Insightly", + "insightly" + ], + "category": "sales" + }, + { + "slug": "intercom-api", + "toolkit": "intercom-api", + "name": "Intercom API", + "logoUrl": "https://design-system.arcade.dev/icons/intercom.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/intercom-api", + "aliases": [ + "IntercomApi", + "intercom-api" + ], + "category": "customer-support" + }, + { + "slug": "jira", + "toolkit": "jira", + "name": "Jira", + "logoUrl": "https://design-system.arcade.dev/icons/jira.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/jira", + "aliases": [ + "Jira", + "jira" + ], + "category": "productivity" + }, + { + "slug": "linear", + "toolkit": "linear", + "name": "Linear", + "logoUrl": "https://design-system.arcade.dev/icons/linear.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/linear", + "aliases": [ + "Linear", + "linear" + ], + "category": "productivity" + }, + { + "slug": "linkedin", + "toolkit": "linkedin", + "name": "LinkedIn", + "logoUrl": "https://design-system.arcade.dev/icons/linkedin.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/linkedin", + "aliases": [ + "Linkedin", + "linkedin" + ], + "category": "social" + }, + { + "slug": "luma-api", + "toolkit": "luma-api", + "name": "Luma API", + "logoUrl": "https://design-system.arcade.dev/icons/luma.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/luma-api", + "aliases": [ + "LumaApi", + "luma-api" + ], + "category": "productivity" + }, + { + "slug": "mailchimp-marketing-api", + "toolkit": "mailchimp-marketing-api", + "name": "Mailchimp API", + "logoUrl": "https://design-system.arcade.dev/icons/mailchimp.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/mailchimp-marketing-api", + "aliases": [ + "MailchimpMarketingApi", + "mailchimp-marketing-api" + ], + "category": "productivity" + }, + { + "slug": "math", + "toolkit": "math", + "name": "Math", + "logoUrl": "https://design-system.arcade.dev/icons/math-toolkit.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/math", + "aliases": [ + "Math", + "math" + ], + "category": "development" + }, + { + "slug": "microsoft-excel", + "toolkit": "microsoft-excel", + "name": "Microsoft Excel", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-excel.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-excel", + "aliases": [ + "MicrosoftExcel", + "microsoft-excel" + ], + "category": "productivity" + }, + { + "slug": "microsoft-onedrive", + "toolkit": "microsoft-onedrive", + "name": "Microsoft OneDrive", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-onedrive.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-onedrive", + "aliases": [ + "MicrosoftOnedrive", + "microsoft-onedrive" + ], + "category": "productivity" + }, + { + "slug": "microsoft-outlook-calendar", + "toolkit": "microsoft-outlook-calendar", + "name": "Microsoft Outlook Calendar", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-outlook-calendar.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-outlook-calendar", + "aliases": [ + "MicrosoftOutlookCalendar", + "microsoft-outlook-calendar" + ], + "category": "productivity" + }, + { + "slug": "microsoft-outlook-mail", + "toolkit": "microsoft-outlook-mail", + "name": "Microsoft Outlook Mail", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-outlook-mail.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-outlook-mail", + "aliases": [ + "MicrosoftOutlookMail", + "microsoft-outlook-mail" + ], + "category": "productivity" + }, + { + "slug": "microsoft-power-bi", + "toolkit": "microsoft-power-bi", + "name": "Microsoft Power BI", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-power-bi.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-power-bi", + "aliases": [ + "MicrosoftPowerBI", + "microsoft-power-bi" + ], + "category": "productivity" + }, + { + "slug": "microsoft-powerpoint", + "toolkit": "microsoft-powerpoint", + "name": "Microsoft PowerPoint", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-powerpoint.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-powerpoint", + "aliases": [ + "MicrosoftPowerpoint", + "microsoft-powerpoint" + ], + "category": "productivity" + }, + { + "slug": "microsoft-sharepoint", + "toolkit": "microsoft-sharepoint", + "name": "Microsoft SharePoint", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-sharepoint.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-sharepoint", + "aliases": [ + "MicrosoftSharePoint", + "microsoft-sharepoint" + ], + "category": "productivity" + }, + { + "slug": "microsoft-teams", + "toolkit": "microsoft-teams", + "name": "Microsoft Teams", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-teams.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/microsoft-teams", + "aliases": [ + "MicrosoftTeams", + "microsoft-teams" + ], + "category": "social" + }, + { + "slug": "microsoft-users", + "toolkit": "microsoft-users", + "name": "Microsoft Users", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-users", + "aliases": [ + "MicrosoftUsers", + "microsoft-users" + ], + "category": "productivity" + }, + { + "slug": "microsoft-word", + "toolkit": "microsoft-word", + "name": "Microsoft Word", + "logoUrl": "https://design-system.arcade.dev/icons/microsoft-word.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/microsoft-word", + "aliases": [ + "MicrosoftWord", + "microsoft-word" + ], + "category": "productivity" + }, + { + "slug": "miro-api", + "toolkit": "miro-api", + "name": "Miro API", + "logoUrl": "https://design-system.arcade.dev/icons/miro.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/miro-api", + "aliases": [ + "MiroApi", + "miro-api" + ], + "category": "productivity" + }, + { + "slug": "mixpanel", + "toolkit": "mixpanel", + "name": "Mixpanel", + "logoUrl": "https://design-system.arcade.dev/icons/mixpanel.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/mixpanel", + "aliases": [ + "Mixpanel", + "mixpanel" + ], + "category": "development" + }, + { + "slug": "mongodb", + "toolkit": "mongodb", + "name": "MongoDB", + "logoUrl": "https://design-system.arcade.dev/icons/mongodb.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/mongodb", + "aliases": [ + "MongoDB", + "mongodb" + ], + "category": "databases" + }, + { + "slug": "nimble", + "toolkit": "nimble", + "name": "Nimble", + "logoUrl": "https://docs.arcade.dev/images/partners/nimble.png", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/nimble", + "aliases": [ + "Nimble", + "nimble" + ], + "category": "search" + }, + { + "slug": "notion", + "toolkit": "notion", + "name": "Notion", + "logoUrl": "https://design-system.arcade.dev/icons/notion.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/notion", + "aliases": [ + "Notion", + "notion" + ], + "category": "productivity" + }, + { + "slug": "obsidian", + "toolkit": "obsidian", + "name": "Obsidian", + "logoUrl": "https://design-system.arcade.dev/icons/obsidian.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "Obsidian", + "obsidian" + ], + "category": "productivity" + }, + { + "slug": "pagerduty", + "toolkit": "pagerduty", + "name": "PagerDuty", + "logoUrl": "https://design-system.arcade.dev/icons/pagerduty.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/pagerduty", + "aliases": [ + "PagerDuty", + "pagerduty" + ], + "category": "development" + }, + { + "slug": "pagerduty-api", + "toolkit": "pagerduty-api", + "name": "PagerDuty API", + "logoUrl": "https://design-system.arcade.dev/icons/pagerduty.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/pagerduty-api", + "aliases": [ + "PagerDutyApi", + "pagerduty-api" + ], + "category": "customer-support" + }, + { + "slug": "postgres", + "toolkit": "postgres", + "name": "Postgres", + "logoUrl": "https://design-system.arcade.dev/icons/postgres.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/postgres", + "aliases": [ + "Postgres", + "postgres" + ], + "category": "databases" + }, + { + "slug": "posthog", + "toolkit": "posthog", + "name": "PostHog", + "logoUrl": "https://design-system.arcade.dev/icons/posthog.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/posthog", + "aliases": [ + "Posthog", + "posthog" + ], + "category": "development" + }, + { + "slug": "posthog-api", + "toolkit": "posthog-api", + "name": "PostHog API", + "logoUrl": "https://design-system.arcade.dev/icons/posthog.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/posthog-api", + "aliases": [ + "PosthogApi", + "posthog-api" + ], + "category": "development" + }, + { + "slug": "postman", + "toolkit": "postman", + "name": "Postman", + "logoUrl": "https://design-system.arcade.dev/icons/postman.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/postman", + "aliases": [ + "Postman", + "postman" + ], + "category": "development" + }, + { + "slug": "pylon", + "toolkit": "pylon", + "name": "Pylon", + "logoUrl": "https://design-system.arcade.dev/icons/pylon.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/pylon", + "aliases": [ + "Pylon", + "pylon" + ], + "category": "customer-support" + }, + { + "slug": "pylon-api", + "toolkit": "pylon-api", + "name": "Pylon API", + "logoUrl": "https://design-system.arcade.dev/icons/pylon.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/pylon-api", + "aliases": [ + "PylonApi", + "pylon-api" + ], + "category": "customer-support" + }, + { + "slug": "reddit", + "toolkit": "reddit", + "name": "Reddit", + "logoUrl": "https://design-system.arcade.dev/icons/reddit.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/reddit", + "aliases": [ + "Reddit", + "reddit" + ], + "category": "social" + }, + { + "slug": "resend", + "toolkit": "resend", + "name": "Resend", + "logoUrl": "https://design-system.arcade.dev/icons/resend.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/resend", + "aliases": [ + "Resend", + "resend" + ], + "category": "productivity" + }, + { + "slug": "salesforce", + "toolkit": "salesforce", + "name": "Salesforce", + "logoUrl": "https://design-system.arcade.dev/icons/salesforce.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/sales/salesforce", + "aliases": [ + "Salesforce", + "salesforce" + ], + "category": "sales" + }, + { + "slug": "servicenow", + "toolkit": "servicenow", + "name": "ServiceNow", + "logoUrl": "https://design-system.arcade.dev/icons/servicenow.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/servicenow", + "aliases": [ + "ServiceNow", + "servicenow" + ], + "category": "customer-support" + }, + { + "slug": "slack", + "toolkit": "slack", + "name": "Slack", + "logoUrl": "https://design-system.arcade.dev/icons/slack.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/slack", + "aliases": [ + "Slack", + "slack" + ], + "category": "social" + }, + { + "slug": "slack-api", + "toolkit": "slack-api", + "name": "Slack API", + "logoUrl": "https://design-system.arcade.dev/icons/slack.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/slack-api", + "aliases": [ + "SlackApi", + "slack-api" + ], + "category": "social" + }, + { + "slug": "snowflake", + "toolkit": "snowflake", + "name": "Snowflake", + "logoUrl": "https://design-system.arcade.dev/icons/snowflake.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/snowflake", + "aliases": [ + "Snowflake", + "snowflake" + ], + "category": "databases" + }, + { + "slug": "spotify", + "toolkit": "spotify", + "name": "Spotify", + "logoUrl": "https://design-system.arcade.dev/icons/spotify.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/entertainment/spotify", + "aliases": [ + "Spotify", + "spotify" + ], + "category": "entertainment" + }, + { + "slug": "squareup-api", + "toolkit": "squareup-api", + "name": "SquareUp API", + "logoUrl": "https://design-system.arcade.dev/icons/square.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/squareup-api", + "aliases": [ + "SquareUpApi", + "squareup-api" + ], + "category": "productivity" + }, + { + "slug": "stripe", + "toolkit": "stripe", + "name": "Stripe", + "logoUrl": "https://design-system.arcade.dev/icons/stripe.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/payments/stripe", + "aliases": [ + "Stripe", + "stripe" + ], + "category": "payments" + }, + { + "slug": "stripe-api", + "toolkit": "stripe_api", + "name": "Stripe API", + "logoUrl": "https://design-system.arcade.dev/icons/stripe.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/payments/stripe_api", + "aliases": [ + "StripeApi", + "stripe_api" + ], + "category": "payments" + }, + { + "slug": "tavily", + "toolkit": "tavily", + "name": "Tavily", + "logoUrl": "https://docs.arcade.dev/images/partners/tavily.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/tavily", + "aliases": [ + "Tavily", + "tavily" + ], + "category": "search" + }, + { + "slug": "telegram", + "toolkit": "telegram", + "name": "Telegram", + "logoUrl": "https://design-system.arcade.dev/icons/telegram.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/telegram", + "aliases": [ + "Telegram", + "telegram" + ], + "category": "social" + }, + { + "slug": "ticktick-api", + "toolkit": "ticktick-api", + "name": "TickTick API", + "logoUrl": "https://design-system.arcade.dev/icons/ticktick.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/ticktick-api", + "aliases": [ + "TickTickApi", + "ticktick-api" + ], + "category": "productivity" + }, + { + "slug": "trello-api", + "toolkit": "trello-api", + "name": "Trello API", + "logoUrl": "https://design-system.arcade.dev/icons/trello.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/trello-api", + "aliases": [ + "TrelloApi", + "trello-api" + ], + "category": "productivity" + }, + { + "slug": "twilio", + "toolkit": "twilio", + "name": "Twilio", + "logoUrl": "https://design-system.arcade.dev/icons/twilio.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "Twilio", + "twilio" + ], + "category": "social" + }, + { + "slug": "twitch", + "toolkit": "twitch", + "name": "Twitch", + "logoUrl": "https://design-system.arcade.dev/icons/twitch.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations", + "aliases": [ + "Twitch", + "twitch" + ], + "category": "entertainment" + }, + { + "slug": "vercel", + "toolkit": "vercel", + "name": "Vercel", + "logoUrl": "https://design-system.arcade.dev/icons/vercel.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/vercel", + "aliases": [ + "Vercel", + "vercel" + ], + "category": "development" + }, + { + "slug": "vercel-api", + "toolkit": "vercel-api", + "name": "Vercel API", + "logoUrl": "https://design-system.arcade.dev/icons/vercel.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/vercel-api", + "aliases": [ + "VercelApi", + "vercel-api" + ], + "category": "development" + }, + { + "slug": "walmart", + "toolkit": "walmart", + "name": "Walmart", + "logoUrl": "https://design-system.arcade.dev/icons/walmart.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/walmart", + "aliases": [ + "Walmart", + "walmart" + ], + "category": "search" + }, + { + "slug": "weaviate-api", + "toolkit": "weaviate-api", + "name": "Weaviate API", + "logoUrl": "https://design-system.arcade.dev/icons/weaviate.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/weaviate-api", + "aliases": [ + "WeaviateApi", + "weaviate-api" + ], + "category": "databases" + }, + { + "slug": "workday", + "toolkit": "workday", + "name": "Workday", + "logoUrl": "https://design-system.arcade.dev/icons/workday.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/workday", + "aliases": [ + "Workday", + "workday" + ], + "category": "productivity" + }, + { + "slug": "x", + "toolkit": "x", + "name": "X", + "logoUrl": "https://design-system.arcade.dev/icons/x.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/x", + "aliases": [ + "X", + "x" + ], + "category": "social" + }, + { + "slug": "xero-api", + "toolkit": "xero-api", + "name": "Xero API", + "logoUrl": "https://design-system.arcade.dev/icons/xero.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/productivity/xero-api", + "aliases": [ + "XeroApi", + "xero-api" + ], + "category": "productivity" + }, + { + "slug": "youtube", + "toolkit": "youtube", + "name": "Youtube", + "logoUrl": "https://design-system.arcade.dev/icons/youtube.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/search/youtube", + "aliases": [ + "Youtube", + "youtube" + ], + "category": "search" + }, + { + "slug": "yugabytedb", + "toolkit": "yugabytedb", + "name": "YugabyteDB", + "logoUrl": "https://design-system.arcade.dev/icons/yugabytedb.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/databases/yugabytedb", + "aliases": [ + "YugabyteDB", + "yugabytedb" + ], + "category": "databases" + }, + { + "slug": "zendesk", + "toolkit": "zendesk", + "name": "Zendesk", + "logoUrl": "https://design-system.arcade.dev/icons/zendesk.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/customer-support/zendesk", + "aliases": [ + "Zendesk", + "zendesk" + ], + "category": "customer-support" + }, + { + "slug": "zoho-books-api", + "toolkit": "zoho-books-api", + "name": "Zoho Books API", + "logoUrl": "https://design-system.arcade.dev/icons/zoho-books.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/payments/zoho-books-api", + "aliases": [ + "ZohoBooksApi", + "zoho-books-api" + ], + "category": "payments" + }, + { + "slug": "zoho-creator-api", + "toolkit": "zoho-creator-api", + "name": "Zoho Creator API", + "logoUrl": "https://design-system.arcade.dev/icons/zoho-creator.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/development/zoho-creator-api", + "aliases": [ + "ZohoCreatorApi", + "zoho-creator-api" + ], + "category": "development" + }, + { + "slug": "zoom", + "toolkit": "zoom", + "name": "Zoom", + "logoUrl": "https://design-system.arcade.dev/icons/zoom.svg", + "docsUrl": "https://docs.arcade.dev/en/resources/integrations/social-communication/zoom", + "aliases": [ + "Zoom", + "zoom" + ], + "category": "social" + } + ] +} diff --git a/packages/shared/src/composio-app-setup.ts b/packages/shared/src/composio-app-setup.ts new file mode 100644 index 0000000000..75f544f564 --- /dev/null +++ b/packages/shared/src/composio-app-setup.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +export const composioAppSetupSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("start") }).strict(), + z.object({ action: z.literal("status") }).strict(), + z.object({ action: z.literal("complete") }).strict(), +]); +export type ComposioAppSetupInput = z.infer; +export interface ComposioAppSetupResult { + status: "not_connected" | "authorization_required" | "connected"; + authorizationUrl?: string; +} + +export interface ComposioAppAccount { + id: string; + alias: string | null; + status: string; + isDefault: boolean; + /** Non-secret metadata for other aggregator inventories; absent in legacy Composio rows. */ + appSlug?: string; + appName?: string; + managementUrl?: string | null; + healthCheckedAt?: string | null; +} + +/** Provider observations only; this never grants agent access. */ +export interface ComposioAppSnapshot { + connectionId: string; + toolkit: string; + status: "connected" | "not_connected"; + accounts: ComposioAppAccount[]; + checkedAt: string; + errorAt?: string | null; +} + +export interface ComposioAppSyncState { + status: "idle" | "syncing" | "ready" | "error"; + /** Connect OAuth currently supports checks of catalog toolkits, not list-all enumeration. */ + coverage: "supported_catalog"; + checked: number; + total: number; + failed: number; + lastCompletedAt: string | null; + error: string | null; +} + +export interface ComposioAppsResponse { + apps: ComposioAppSnapshot[]; + sync: ComposioAppSyncState; +} + +export const composioAppsSyncSchema = z.object({ force: z.boolean().default(false) }).strict(); + +export const composioAppsRefreshSchema = z.object({ + toolkits: z.array(z.string().min(1).max(200)).max(64).default([]), +}).strict(); + +const accountId = z.string().min(1).max(200); +export const composioAppAccountSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("add") }).strict(), + z.object({ action: z.literal("rename"), accountId, alias: z.string().trim().min(1).max(100) }).strict(), + z.object({ action: z.literal("remove"), accountId }).strict(), +]); +export type ComposioAppAccountInput = z.infer; diff --git a/packages/shared/src/connection-intent-guidance.ts b/packages/shared/src/connection-intent-guidance.ts index 3078023901..8c63bb36c2 100644 --- a/packages/shared/src/connection-intent-guidance.ts +++ b/packages/shared/src/connection-intent-guidance.ts @@ -9,13 +9,14 @@ export const CONNECTION_INTENT_AGENT_GUIDANCE = [ "Connection tools:", "- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.", "- Use the returned service identifiers and connection tools for setup. Respect recorded user choices; never invent access or ask for credentials in comments.", + "- If installed tools are Off or the connection is not enabled for this agent, call `connection_request` with the saved connectionId and required toolNames. It creates a scoped human access card; never substitute a generic permission question or change permissions yourself.", "- When waiting for user action, finish independent work, then yield without retrying or polling. On continuation, follow the recorded outcome and use the installed connection.", "- Do not use connection tools for arbitrary MCP URLs or unrelated work.", ].join("\n"); export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or a natural-language description (up to 4000 characters). Results tolerate extra words, split names, and small typos. Choose the relevant match using its description and method purpose. Follow the returned instruction to present an inline setup card with connection_request or share the method's setupPath. Also discovers verified external aggregator apps. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only."; -export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search as available or needs_user_action. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments."; +export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search. For an existing connection with missing agent access, pass connectionId and the exact required toolNames to create an embedded Grant access card; only the human can approve it. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments."; export const CONNECTION_RUNTIME_TOOL_NAMES = [ "connections_search", diff --git a/packages/shared/src/connection-routing.ts b/packages/shared/src/connection-routing.ts index 427437722d..7c49dbd3b1 100644 --- a/packages/shared/src/connection-routing.ts +++ b/packages/shared/src/connection-routing.ts @@ -7,6 +7,8 @@ import { type RemoteMcpConnectorId, } from "./remote-mcp-connectors.js"; import composioCatalog from "./composio-search-catalog.json" with { type: "json" }; +import arcadeCatalog from "./arcade-app-catalog.json" with { type: "json" }; +import { aggregatorAppIdentity } from "./aggregator-app-catalog.js"; import { prepareConnectionSearch, scoreConnectionSearch } from "./connection-search.js"; export const AGGREGATOR_PRIORITY = [ @@ -214,6 +216,19 @@ for (const [toolkit, name] of composioCatalog.toolkits as Array<[string, string] } } +for (const app of arcadeCatalog.apps) { + const existing = AGGREGATOR_SUPPORT_INDEX.find((entry) => + entry.slug === app.slug || aggregatorAppIdentity(entry.name) === aggregatorAppIdentity(app.name)); + if (existing) { + existing.aliases = [...new Set([...existing.aliases, app.slug, ...app.aliases])]; + existing.providers = { ...existing.providers, arcade: arcadeCatalog.verifiedAt }; + existing.evidenceUrls = { ...existing.evidenceUrls, arcade: app.docsUrl }; + } else { + AGGREGATOR_SUPPORT_INDEX.push({ slug: app.slug, name: app.name, aliases: app.aliases, + providers: { arcade: arcadeCatalog.verifiedAt }, evidenceUrls: { arcade: app.docsUrl } }); + } +} + export function searchAggregatorServices(query: string | ReturnType) { const prepared = typeof query === "string" ? prepareConnectionSearch(query) : query; return AGGREGATOR_SUPPORT_INDEX.map(service => ({ service, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a500332ce0..f4a7e0ab28 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export { composioAppSetupSchema, composioAppsRefreshSchema, composioAppsSyncSchema, composioAppAccountSchema, type ComposioAppSetupInput, type ComposioAppSetupResult, type ComposioAppAccountInput, type ComposioAppAccount, type ComposioAppSnapshot, type ComposioAppSyncState, type ComposioAppsResponse } from "./composio-app-setup.js"; export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; export { RUNNER_GOAL_MAX_OBJECTIVE_CHARS, @@ -2819,3 +2820,4 @@ export * from "./browser-use.js"; export * from "./types/skill-source.js"; export * from "./validators/skill-source.js"; export * from "./github-skill-repository.js"; +export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema, type AggregatorAppSnapshot, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "./aggregator-apps.js"; diff --git a/packages/shared/src/types/connection-intent.ts b/packages/shared/src/types/connection-intent.ts index 8a0dce3916..9dcf1a7e05 100644 --- a/packages/shared/src/types/connection-intent.ts +++ b/packages/shared/src/types/connection-intent.ts @@ -62,6 +62,7 @@ export type ConnectionIntentSetupConnection = Pick; + }; upstreamService?: { slug: string; name: string; selectionInteractionId?: string }; /** AI authentication and inbox setup cannot be satisfied by tool credentials. */ purpose?: "ai" | "channel"; diff --git a/packages/shared/src/validators/connection-intent.test.ts b/packages/shared/src/validators/connection-intent.test.ts index 609569e31a..fdc314617d 100644 --- a/packages/shared/src/validators/connection-intent.test.ts +++ b/packages/shared/src/validators/connection-intent.test.ts @@ -10,6 +10,12 @@ import { const agentId = "11111111-1111-4111-8111-111111111111"; describe("connection intent contracts", () => { + it("accepts bounded exact access requests without caller-supplied agent identity", () => { + expect(connectionRequestInputSchema.parse({ service: "composio", connectionId: agentId, toolNames: ["COMPOSIO_SEARCH_TOOLS"] })).toMatchObject({ connectionId: agentId }); + expect(connectionRequestInputSchema.safeParse({ service: "composio", toolNames: ["read", "read"] }).success).toBe(false); + expect(connectionRequestInputSchema.safeParse({ service: "composio", toolNames: Array.from({ length: 21 }, (_, i) => `tool-${i}`) }).success).toBe(false); + expect(connectionRequestInputSchema.safeParse({ service: "composio", agentId }).success).toBe(false); + }); it("accepts the versioned server-authored payload and safe phases", () => { expect(connectionIntentPayloadSchema.parse({ version: 1, @@ -45,6 +51,18 @@ describe("connection intent contracts", () => { expect(connectionIntentResultSchema.parse({ version: 1, outcome: "declined" }).outcome).toBe("declined"); }); + it("bounds the server-authored grant and rejects duplicate or unreviewable tools", () => { + const tool = { catalogEntryId: agentId, toolName: "search", versionHash: "v1", permission: "allowed" }; + const base = { version: 1, serviceSlug: "composio", serviceName: "Composio", requestingAgentId: agentId, + requestingAgentName: "CEO", phase: "requested" }; + const payload = (tools: unknown[]) => ({ ...base, accessRequest: { connectionId: agentId, connectionName: "Account", tools } }); + expect(connectionIntentPayloadSchema.safeParse(payload([tool])).success).toBe(true); + expect(connectionIntentPayloadSchema.safeParse(payload([])).success).toBe(false); + expect(connectionIntentPayloadSchema.safeParse(payload([tool, tool])).success).toBe(false); + expect(connectionIntentPayloadSchema.safeParse(payload([{ ...tool, versionHash: "" }])).success).toBe(false); + expect(connectionIntentPayloadSchema.safeParse(payload([{ ...tool, permission: "allow_all" }])).success).toBe(false); + }); + it("keeps generic interaction creation closed to the server-owned kind", () => { expect(createIssueThreadInteractionSchema.safeParse({ kind: "connection_intent", diff --git a/packages/shared/src/validators/connection-intent.ts b/packages/shared/src/validators/connection-intent.ts index 5bc7b48cbd..33b4ceb713 100644 --- a/packages/shared/src/validators/connection-intent.ts +++ b/packages/shared/src/validators/connection-intent.ts @@ -7,6 +7,8 @@ export const connectionsSearchInputSchema = z.object({ export const connectionRequestInputSchema = z.object({ service: z.string().trim().min(1).max(120), + connectionId: z.string().guid().optional().describe("Reuse this saved connection; never create a replacement gateway"), + toolNames: z.array(z.string().trim().min(1).max(160)).min(1).max(20).refine(names => new Set(names).size === names.length, "Requested tools must be unique").optional().describe("Exact indexed tool names needed by this agent; writes require approval"), selectionInteractionId: z.string().guid().optional(), targetService: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).optional().describe("App slug returned by search only when the user explicitly named this external provider"), }).strict(); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 6545baa9f7..d39f184509 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1111,6 +1111,16 @@ const connectionIntentBrandAssetSchema = z export const connectionIntentPayloadSchema = z .object({ + accessRequest: z.object({ + connectionId: z.string().guid(), + connectionName: z.string().trim().min(1).max(160), + tools: z.array(z.object({ + catalogEntryId: z.string().guid(), + toolName: z.string().trim().min(1).max(160), + versionHash: z.string().min(1).max(256), + permission: z.enum(["allowed", "ask_first"]), + }).strict()).min(1).max(20).refine(tools => new Set(tools.map(tool => tool.catalogEntryId)).size === tools.length, "Requested tools must be unique"), + }).strict().optional(), upstreamService: z.object({ slug: z.string().min(1).max(120), name: z.string().min(1).max(160), selectionInteractionId: z.string().guid().optional() }).strict().optional(), purpose: z.enum(["ai", "channel"]).optional(), version: z.literal(1), diff --git a/scripts/update-arcade-app-catalog.mjs b/scripts/update-arcade-app-catalog.mjs new file mode 100644 index 0000000000..4ff3ea7ed8 --- /dev/null +++ b/scripts/update-arcade-app-catalog.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Refresh only Arcade's public support claim; never update Composio verification dates. +import { readFile, writeFile } from "node:fs/promises"; +const source = "https://docs.arcade.dev/en/resources/integrations"; +const args = process.argv.slice(2); +const verifiedAt = args[args.indexOf("--verified-at") + 1]; +if (!args.includes("--verified-at") || !/^\d{4}-\d{2}-\d{2}$/.test(verifiedAt)) { + throw new Error("Supply --verified-at YYYY-MM-DD after reviewing the official public Arcade catalog."); +} +const input = args.includes("--input") ? args[args.indexOf("--input") + 1] : null; +let html; +if (input) html = await readFile(input, "utf8"); +else { + const response = await fetch(source); + if (!response.ok) throw new Error(`Arcade catalog request failed (${response.status})`); + html = await response.text(); +} +const records = [...html.matchAll(/self\.__next_f\.push\(\[1,("(?:\\.|[^"\\])*?")\]\)/g)] + .map((match) => JSON.parse(match[1])).join(""); +const start = records.indexOf('"toolkits":['); +if (start < 0) throw new Error("Arcade catalog payload was not found; preserve the previous snapshot."); +const rest = records.slice(start + '"toolkits":'.length); +let end = 0, depth = 0, quoted = false, escaped = false; +for (; end < rest.length; end++) { + const char = rest[end]; + if (escaped) { escaped = false; continue; } + if (quoted && char === "\\") { escaped = true; continue; } + if (char === '"') { quoted = !quoted; continue; } + if (!quoted && char === "[") depth++; + if (!quoted && char === "]" && --depth === 0) { end++; break; } +} +const toolkits = JSON.parse(rest.slice(0, end)); +const apps = toolkits.filter((app) => app.isComingSoon === false && app.isHidden === false).map((app) => { + const toolkit = app.relativeDocsLink.split("/").at(-1); + const slug = toolkit.replaceAll("_", "-"); + const logoUrl = new URL(app.publicIconUrl, source); + const docsUrl = new URL(app.hasPage ? app.docsLink : source); + if (!/^[a-z0-9-]+$/.test(slug) || !app.label || typeof app.id !== "string" + || logoUrl.protocol !== "https:" || !["docs.arcade.dev", "design-system.arcade.dev"].includes(logoUrl.hostname) + || docsUrl.origin !== "https://docs.arcade.dev") throw new Error(`Invalid public Arcade entry: ${app.id}`); + return { slug, toolkit, name: app.label, logoUrl: logoUrl.href, docsUrl: docsUrl.href, aliases: [app.id, toolkit], category: app.category }; +}).sort((a, b) => a.slug.localeCompare(b.slug, "en")); +if (apps.length < 100 || new Set(apps.map((app) => app.slug)).size !== apps.length) { + throw new Error("Arcade catalog is incomplete or contains duplicate slugs; preserve the previous snapshot."); +} +const target = new URL("../packages/shared/src/arcade-app-catalog.json", import.meta.url); +await writeFile(target, `${JSON.stringify({ source, verifiedAt, apps }, null, 2)}\n`); +console.log(`Indexed ${apps.length} public Arcade apps (${verifiedAt}).`); diff --git a/server/src/__tests__/aggregator-app-discovery.test.ts b/server/src/__tests__/aggregator-app-discovery.test.ts new file mode 100644 index 0000000000..3f1af786da --- /dev/null +++ b/server/src/__tests__/aggregator-app-discovery.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { AggregatorDiscoveryUnavailableError, discoverArcadeApps, discoverExecutorApps, EXECUTOR_INVENTORY_CODE, inventoryPayload } from "../services/aggregator-app-discovery.js"; +import { aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps"; + +const account = (id: string, user = "u1") => ({ id, user_id: user, provider_id: "notion-provider", connection_status: "active", provider_user_info: { email: "Work", access_token: "do-not-store" } }); +const tool = { qualified_name: "Notion.ListPages", toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion-provider", token_status: "completed" } } }; + +describe("aggregator inventory adapters", () => { + it("paginates Arcade, scopes the user and exposed tools, preserves distinct accounts, and strips credentials", async () => { + const request = vi.fn(async (path: string) => path.startsWith("/v1/tools") ? { items: [tool, { ...tool, toolkit: { name: "Private" }, qualified_name: "Private.Read" }] } + : path.includes("offset=100") ? { items: [account("last"), account("foreign", "u2")], total_count: 102, offset: 0 } + : { items: Array.from({ length: 100 }, (_, index) => account(String(index))), total_count: 102, offset: 100 }); + const apps = await discoverArcadeApps({ request, userId: "u1", gatewayTools: ["Notion_ListPages"] }); + expect(apps).toHaveLength(1); + expect(apps[0].accounts).toHaveLength(101); + expect(apps[0].accounts[0]).toMatchObject({ appSlug: "notion", status: "ACTIVE", alias: "Work" }); + expect(JSON.stringify(apps)).not.toContain("do-not-store"); + expect(request.mock.calls[0][0]).toContain("user[id]=u1"); + }); + it("uses Arcade's next offset after a short page and rejects truncated final pages", async () => { + const request = vi.fn(async (path: string) => path.startsWith("/v1/tools") ? { items: [tool], total_count: 1, offset: 0 } + : path.includes("offset=1") ? { items: [account("second")], total_count: 2, offset: 0 } + : { items: [account("first")], total_count: 2, offset: 1 }); + expect((await discoverArcadeApps({ request, userId: "u1", gatewayTools: ["Notion.ListPages"] }))[0].accounts).toHaveLength(2); + expect(request.mock.calls.some(([path]) => path.includes("offset=1"))).toBe(true); + await expect(discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => path.startsWith("/v1/tools") + ? { items: [tool], total_count: 1, offset: 0 } : { items: [account("one")], total_count: 2, offset: 0 } })).rejects.toThrow("Incomplete"); + }); + it("rejects an Arcade partial page rather than returning an authoritative empty inventory", async () => { + for (const items of [[], [account("one")]]) await expect(discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => path.startsWith("/v1/tools") ? { items: [tool] } : { items, total: 10 } })).rejects.toThrow("Incomplete"); + }); + it("does not claim Arcade authorization from account existence alone", async () => { + const apps = await discoverArcadeApps({ userId: "u1", gatewayTools: ["Notion.ListPages"], request: async path => ({ items: path.startsWith("/v1/tools") ? [{ ...tool, requirements: { ...tool.requirements, met: false } }] : [account("1")] }) }); + expect(apps[0].accounts[0].status).toBe("UNVERIFIED"); + }); + it("enumerates Executor pages and uses owner/integration/account identity with honest health", async () => { + const call = vi.fn(async (_name, args) => ({ result: { structuredContent: { items: [{ integration: args.offset ? "custom-notion-helper" : "notion", integrationName: "Custom helper", connection: "Work", owner: args.offset ? "user" : "org", identityLabel: "Work", lastHealth: args.offset ? null : { status: "healthy", checkedAt: 1000 }, secret: "never" }], hasMore: !args.offset, nextOffset: args.offset ? null : 50 } } })); + const apps = await discoverExecutorApps({ call, toolNames: ["integrations"], managementUrl: "https://executor.example/team/integrations" }); + expect(apps[0].accounts[0]).toMatchObject({ appSlug: "notion", status: "ACTIVE", healthCheckedAt: "1970-01-01T00:00:01.000Z" }); + expect(apps[1].accounts[0]).toMatchObject({ appSlug: "executor:custom-notion-helper", status: "UNVERIFIED" }); + expect(JSON.stringify(apps)).not.toContain("never"); + expect(call).toHaveBeenCalledTimes(2); + }); + it("uses fixed read-only code and preserves an upstream workspace management link", async () => { + const call = vi.fn(async () => ({ structuredContent: { status: "completed", result: { connections: [{ integration: "notion", name: "Work", owner: "user", lastHealth: { status: "expired" } }], integrations: [{ slug: "notion" }], managementUrls: { notion: "https://self-host.example/my-org/integrations/notion?addAccount=1" } } } })); + const apps = await discoverExecutorApps({ call, toolNames: ["execute"] }); + expect(call).toHaveBeenCalledWith("execute", { code: EXECUTOR_INVENTORY_CODE }); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + expect(await new AsyncFunction("tools", EXECUTOR_INVENTORY_CODE)({})).toEqual({ discoveryUnavailable: true }); + expect(apps[0].accounts[0]).toMatchObject({ status: "EXPIRED", managementUrl: "https://self-host.example/my-org/integrations/notion" }); + }); + it("rejects unsupported, failed, malformed and truncated Executor inventories", async () => { + await expect(discoverExecutorApps({ toolNames: [], call: vi.fn() })).rejects.toThrow("unavailable"); + await expect(discoverExecutorApps({ toolNames: ["execute"], call: async () => ({ structuredContent: { result: { incompatible: true } } }) })).rejects.toBeInstanceOf(AggregatorDiscoveryUnavailableError); + await expect(discoverExecutorApps({ toolNames: ["execute"], call: async () => ({ isError: true }) })).rejects.toThrow("failed"); + await expect(discoverExecutorApps({ toolNames: ["integrations"], call: async () => ({ items: [], hasMore: true, nextOffset: 0 }) })).rejects.toThrow("Incomplete"); + expect(() => inventoryPayload({ content: [{ type: "text", text: "No accounts" }] })).toThrow(); + }); + it("validates browser destinations without leaking credentials", () => { + expect(aggregatorManagementUrl("composio")).toBe("https://dashboard.composio.dev/~/org/connect/apps"); + for (const url of ["javascript:alert(1)", "https://user:password@executor.example/", "https://executor.example/?api_key=secret"]) expect(aggregatorManagementUrl("executor", url)).toBeNull(); + expect(aggregatorManagementUrl("arcade", "https://other.example")).toBeNull(); + expect(aggregatorManagementUrl("executor", "https://executor.example/team/accounts")).toBe("https://executor.example/team/accounts"); + }); +}); diff --git a/server/src/__tests__/aggregator-app-sync.test.ts b/server/src/__tests__/aggregator-app-sync.test.ts new file mode 100644 index 0000000000..c708213fdc --- /dev/null +++ b/server/src/__tests__/aggregator-app-sync.test.ts @@ -0,0 +1,160 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { eq, sql } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, companyMemberships, connectionGrants, createDb, toolApplications, toolCatalogEntries, toolConnections, toolConnectionAppSnapshots } from "@paperclipai/db"; +import { toolAccessService } from "../services/tool-access.js"; +import { secretService } from "../services/secrets.js"; +import { toolAccessRoutes } from "../routes/tool-access.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +// Optional reuse of the explicitly disposable browser-test cluster on hosts with limited PostgreSQL startup resources. +const acceptanceDatabaseUrl = process.env.PAPERCLIP_AGGREGATOR_TEST_DATABASE_URL; +if (acceptanceDatabaseUrl && !["127.0.0.1", "localhost"].includes(new URL(acceptanceDatabaseUrl).hostname)) throw new Error("The acceptance database must be local and disposable"); +const support = acceptanceDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); +if (!support.supported) console.warn(`Managed-account database tests unavailable: ${support.reason}`); +(support.supported ? describe : describe.skip)("provider-neutral account sync", () => { + let db: ReturnType; + let cleanup: (() => Promise) | undefined; + beforeAll(async () => { + if (acceptanceDatabaseUrl) { db = createDb(acceptanceDatabaseUrl); return; } + const database = await startEmbeddedPostgresTestDatabase("paperclip-aggregator-sync-"); db = createDb(database.connectionString); cleanup = database.cleanup; + }, 90_000); + afterAll(async () => { await cleanup?.(); }); + async function fixture(provider: "arcade" | "executor" = "executor") { + const [company] = await db.insert(companies).values({ name: "Aggregator sync test", issuePrefix: randomUUID().slice(0, 6).toUpperCase() }).returning(); + const userId = randomUUID(); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" }); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: provider, type: "mcp_http", metadata: { sourceTemplateKey: provider } }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: `${provider} gateway`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: provider, url: provider === "arcade" ? "https://api.arcade.dev/mcp/test" : "https://executor.example/mcp", managementUrl: "https://executor.example/team/integrations" } }).returning(); + await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active", isDefault: true }); + const names = provider === "executor" ? ["integrations"] : ["Notion.ListPages"]; + await db.insert(toolCatalogEntries).values(names.map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active", riskLevel: "read" as const }))); + let accounts = [{ integration: "notion", owner: "user", connection: "Work", lastHealth: { status: "healthy", checkedAt: Date.now() } }]; + let fail = false; + let partial = false; + const calls: { url: string; method: string; name?: string; authorization?: string }[] = []; + const access = toolAccessService(db, { remoteHttpRequest: async (url, init) => { + const rpc = init.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url: String(url), method: init.method ?? "GET", name: rpc?.params?.name, authorization: new Headers(init.headers).get("Authorization") ?? undefined }); + if (rpc?.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools: names.map(name => ({ name, inputSchema: { type: "object" } })) } }); + if (fail) return new Response("upstream error with private token", { status: 401 }); + if (!rpc) return Response.json({ items: String(url).includes("/v1/tools") ? [{ qualified_name: names[0], toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion", token_status: "completed" } } }] : [{ id: "arcade-work", provider_id: "notion", user_id: "arcade-user", connection_status: "active", provider_user_info: { email: "Work" } }] }); + return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { items: accounts, hasMore: partial, nextOffset: partial ? 0 : null } } }); + } }); + return { company, connection, actor: { actorType: "user" as const, actorId: userId }, access, calls, disconnect: () => { accounts = []; }, fail: () => { fail = true; }, partial: () => { partial = true; } }; + } + async function sync(f: Awaited>) { + await f.access.syncAggregatorApps(f.connection.id, true, f.actor); + let result = await f.access.listAggregatorApps(f.connection.id, f.actor); + await vi.waitFor(async () => { result = await f.access.listAggregatorApps(f.connection.id, f.actor); expect(result.sync.status).not.toBe("syncing"); }, { timeout: 5000 }); + return result; + } + it("replays the account migrations without losing observations or weakening company boundaries", async () => { + const f = await fixture(); + await sync(f); + const before = await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id)); + for (const name of ["0295_public_captain_cross", "0296_stiff_thaddeus_ross"]) { + const migration = await readFile(new URL(`../../../packages/db/src/migrations/${name}.sql`, import.meta.url), "utf8"); + for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement)); + } + expect(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id))).toEqual(before); + const other = await fixture(); + await expect(db.insert(toolConnectionAppSnapshots).values({ companyId: other.company.id, connectionId: f.connection.id, + userId: other.actor.actorId, credentialKey: "different", toolkit: "notion", status: "connected", accounts: [] })).rejects.toThrow(); + }); + it("imports and reconciles Executor accounts without new executable connections or access grants", async () => { + const f = await fixture(); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id)); + expect((await sync(f)).apps[0]).toMatchObject({ provider: "executor", appSlug: "notion", status: "connected" }); + expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, f.company.id))).toHaveLength(1); + expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants); + f.disconnect(); + expect((await sync(f)).apps.flatMap(app => app.accounts)).toHaveLength(0); + }); + it.each(["fail", "partial"] as const)("retains stale observations after %s and isolates users, gateways and credential changes", async failure => { + const f = await fixture(); await sync(f); f[failure](); + const result = await sync(f); + expect(result.sync.status).toBe("error"); + expect(result.apps[0].accounts).toHaveLength(1); + expect(result.apps[0].errorAt).toBeTruthy(); + expect(JSON.stringify(result)).not.toContain("private token"); + expect((await f.access.listAggregatorApps(f.connection.id, { actorType: "user", actorId: randomUUID() })).apps).toHaveLength(0); + const other = await fixture(); expect((await other.access.listAggregatorApps(other.connection.id, other.actor)).apps).toHaveLength(0); + await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://executor.example/rotated" } }).where(eq(toolConnections.id, f.connection.id)); + expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0); + }); + it("reports unsupported Executor inventory without trying arbitrary tools", async () => { + const f = await fixture(); await sync(f); f.calls.length = 0; await db.update(toolCatalogEntries).set({ status: "inactive" }).where(eq(toolCatalogEntries.connectionId, f.connection.id)); + expect((await f.access.syncAggregatorApps(f.connection.id, true, f.actor)).discovery.availability).toBe("unsupported"); + expect(f.calls).toHaveLength(0); + expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps[0].freshness).toBe("stale"); + }); + it("reuses an existing Arcade API key and secret-backed user header without extra sync setup", async () => { + const f = await fixture("arcade"); + const key = await secretService(db).create(f.company.id, { name: "Arcade project", provider: "local_encrypted", value: "existing-key" }, { userId: f.actor.actorId }); + const user = await secretService(db).create(f.company.id, { name: "Arcade user", provider: "local_encrypted", value: "arcade-user" }, { userId: f.actor.actorId }); + await db.update(toolConnections).set({ authKind: "api_key", credentialRefs: [ + { name: "authorization", secretId: key.id, placement: "header", key: "Authorization", prefix: "Bearer " }, + { name: "headers.Arcade-User-ID", secretId: user.id, placement: "header", key: "Arcade-User-ID", prefix: "" }, + ], credentialSecretRefs: [ + { secretId: key.id, configPath: "credentials.authorization", versionSelector: "latest" }, + { secretId: user.id, configPath: "headers.Arcade-User-ID", versionSelector: "latest" }, + ] }).where(eq(toolConnections.id, f.connection.id)); + for (const [secretId, configPath] of [[key.id, "credentials.authorization"], [user.id, "headers.Arcade-User-ID"]]) await secretService(db).createBinding({ companyId: f.company.id, secretId, targetType: "tool_connection", targetId: f.connection.id, configPath }); + expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).discovery.availability).toBe("available"); + expect((await sync(f)).apps[0]).toMatchObject({ provider: "arcade", appSlug: "notion" }); + expect(f.calls.filter(call => call.method === "GET").every(call => call.authorization === "Bearer existing-key")).toBe(true); + }); + + it("keeps optional Arcade discovery credentials in the vault, uses them only for discovery, and invalidates rotation", async () => { + const f = await fixture("arcade"); + expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).discovery.availability).toBe("setup_required"); + await f.access.configureArcadeDiscovery(f.connection.id, { apiKey: "project-discovery-key", userId: "arcade-user" }, f.actor); + const result = await sync(f); + expect(result.apps[0]).toMatchObject({ provider: "arcade", appSlug: "notion" }); + const [saved] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id)); + expect(JSON.stringify(saved)).not.toContain("project-discovery-key"); + expect(saved.credentialSecretRefs).toHaveLength(0); + expect(f.calls.filter(call => call.method === "POST").every(call => call.authorization !== "Bearer project-discovery-key")).toBe(true); + expect(f.calls.filter(call => call.method === "GET").every(call => call.authorization === "Bearer project-discovery-key")).toBe(true); + const metadata = (saved.config.aggregatorDiscovery as Record)[f.actor.actorId]; + await secretService(db).rotateCurrentUserSecretValue(f.company.id, f.actor.actorId, metadata.secretId, { value: "rotated-discovery-key" }, { userId: f.actor.actorId }); + expect((await f.access.listAggregatorApps(f.connection.id, f.actor)).apps).toHaveLength(0); + expect(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id))).toHaveLength(1); + expect((await sync(f)).apps[0].accounts).toHaveLength(1); + expect(f.calls.filter(call => call.method === "GET").at(-1)?.authorization).toBe("Bearer rotated-discovery-key"); + }); + it("rejects optional sync configuration by ordinary members and agents", async () => { + const f = await fixture("arcade"); + const memberId = randomUUID(); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: memberId, membershipRole: "member", status: "active" }); + const app = express(); app.use(express.json()); + app.use((req, _res, next) => { req.actor = req.headers["x-test-agent"] ? { type: "agent", agentId: randomUUID(), companyId: f.company.id } : { type: "board", userId: memberId, source: "session", isInstanceAdmin: false, companyIds: [f.company.id] }; next(); }); + app.use("/api", toolAccessRoutes(db)); app.use(errorHandler); + for (const agent of [false, true]) { + const response = await request(app).put(`/api/tool-connections/${f.connection.id}/aggregator/discovery`).set("x-test-agent", agent ? "yes" : "").send({ apiKey: "not-saved", userId: "arcade-user" }); + expect(response.status).toBe(403); + } + const [saved] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id)); + expect(saved.config.aggregatorDiscovery).toBeUndefined(); + }); + + it("enforces company membership and connection-manager access on all generic endpoints", async () => { + const f = await fixture("arcade"); + const app = express(); app.use(express.json()); + let viewingUser = randomUUID(); + app.use((req, _res, next) => { req.actor = { type: "board", userId: viewingUser, source: "session", isInstanceAdmin: false, companyIds: viewingUser === f.actor.actorId ? [f.company.id] : [] }; next(); }); + app.use("/api", toolAccessRoutes(db)); app.use(errorHandler); + for (const path of ["/aggregator/apps", "/aggregator/apps/sync", "/aggregator/apps/refresh", "/aggregator/discovery"]) { + const endpoint = `/api/tool-connections/${f.connection.id}${path}`; + const response = path.endsWith("discovery") ? await request(app).put(endpoint).send({ apiKey: "no", userId: "no" }) : path.endsWith("apps") ? await request(app).get(endpoint) : await request(app).post(endpoint).send({}); + expect([403, 404]).toContain(response.status); + } + viewingUser = f.actor.actorId; + await request(app).get(`/api/tool-connections/${f.connection.id}/aggregator/apps`).expect(200).expect("Cache-Control", "private, no-store"); + }); +}); diff --git a/server/src/__tests__/composio-app-setup.test.ts b/server/src/__tests__/composio-app-setup.test.ts new file mode 100644 index 0000000000..5fd447b57c --- /dev/null +++ b/server/src/__tests__/composio-app-setup.test.ts @@ -0,0 +1,337 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, companies, companyMemberships, connectionGrants, createDb, issues, toolApplications, toolCatalogEntries, toolConnections, toolConnectionAppSnapshots } from "@paperclipai/db"; +import { composioAppSetupSchema } from "@paperclipai/shared"; +import { composioAppAccounts, composioAppSetupResult } from "../services/composio-app-setup.js"; +import { toolAccessService } from "../services/tool-access.js"; +import { toolAccessPolicyService } from "../services/tool-access-policy.js"; +import { toolAccessRoutes } from "../routes/tool-access.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("Composio app authorization evidence", () => { + it("completes app setup without an agent selection", () => { + expect(composioAppSetupSchema.parse({ action: "complete" })).toEqual({ action: "complete" }); + expect(composioAppSetupSchema.safeParse({ action: "complete", agentId: randomUUID() }).success).toBe(false); + }); + it("keeps only the requested app's account identities and never treats missing or malformed evidence as an empty list", () => { + const response = { results: { gmail: { accounts: [{ id: "gmail", status: "ACTIVE" }] }, circleback_mcp: { accounts: [{ id: "ca_1", alias: "Work", status: "active", is_default: true, access_token: "secret" }] } } }; + expect(composioAppAccounts(response, "circleback_mcp")).toEqual([{ id: "ca_1", alias: "Work", status: "ACTIVE", isDefault: true }]); + expect(composioAppAccounts(response, "missing")).toBeUndefined(); + expect(composioAppAccounts({ circleback_mcp: { accounts: [] } }, "circleback_mcp")).toEqual([]); + expect(composioAppAccounts({ circleback_mcp: { accounts: [{ status: "active" }] } }, "circleback_mcp")).toBeUndefined(); + expect(() => composioAppAccounts({ circleback_mcp: { error: "expired", accounts: [] } }, "circleback_mcp")).toThrow("could not check"); + }); + it("accepts a hosted link and only verifies the requested toolkit", () => { + expect(composioAppSetupResult({ results: { circleback_mcp: { redirect_url: "https://connect.composio.dev/link/test" } } }, "circleback_mcp")) + .toEqual({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/test" }); + expect(composioAppSetupResult({ results: { gmail: { status: "ACTIVE" }, circleback_mcp: { accounts: [{ status: "INITIATED" }] } } }, "circleback_mcp")) + .toEqual({ status: "not_connected" }); + expect(composioAppSetupResult({ result: { content: [{ type: "text", text: JSON.stringify({ results: { circleback_mcp: { accounts: [{ status: "ACTIVE" }] } } }) }] } }, "circleback_mcp")) + .toEqual({ status: "connected" }); + }); + it("rejects provider failures and untrusted handoff URLs", () => { + expect(() => composioAppSetupResult({ isError: true }, "circleback_mcp")).toThrow("could not configure"); + expect(composioAppSetupResult({ redirect_url: "https://untrusted.example/link/test" }, "circleback_mcp")).toEqual({ status: "not_connected" }); + }); + it("recognizes the lowercase account status returned by the live Composio gateway", () => { + const response = { result: { content: [{ type: "text", text: JSON.stringify({ + data: { results: { circleback_mcp: { toolkit: "circleback_mcp", status: "active", accounts: [{ status: "active", is_default: true }] } } }, + error: null, successful: true, + }) }], isError: false } }; + expect(composioAppSetupResult(response, "circleback_mcp")).toEqual({ status: "connected" }); + expect(composioAppSetupResult(response, "gmail")).toEqual({ status: "not_connected" }); + }); +}); + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("direct Composio setup", () => { + let db: ReturnType; + let cleanup: (() => Promise) | undefined; + beforeAll(async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-composio-setup-"); + db = createDb(database.connectionString); + cleanup = database.cleanup; + }, 20_000); + afterAll(async () => { await cleanup?.(); }); + + async function fixture(syncToolkits: string[] = ["circleback_mcp", "notion", "hubspot"]) { + const [company] = await db.insert(companies).values({ name: "Composio direct setup", issuePrefix: randomUUID().slice(0, 6).toUpperCase() }).returning(); + const userId = randomUUID(); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" }); + const [agent, otherAgent] = await db.insert(agents).values(["Default agent", "Other agent"].map(name => ({ companyId: company.id, name, role: "general" as const, status: "idle" as const, adapterType: "codex_local" }))).returning(); + const [app] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: app.id, uid: randomUUID(), name: "Composio account", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }).returning(); + await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", isDefault: true, status: "active" }); + const catalog = await db.insert(toolCatalogEntries).values([ + ["COMPOSIO_SEARCH_TOOLS", "read"], ["COMPOSIO_GET_TOOL_SCHEMAS", "read"], ["COMPOSIO_MULTI_EXECUTE_TOOL", "destructive"], ["COMPOSIO_MANAGE_CONNECTIONS", "destructive"], + ].map(([toolName, riskLevel]) => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active" as const, riskLevel: riskLevel as "read" | "destructive" }))).returning(); + const accountId = randomUUID(); + let accounts = [{ id: accountId, alias: "Work", status: "initiated", is_default: true, access_token: "never-store-this-token" }]; + let onList = async () => {}; + let listResult: unknown = undefined; + let failedToolkit: string | undefined; + let mutationStatus = 200; + let applyMutation = true; + const operations: string[] = []; + const batches: { name: string; action: string; account_id?: string; alias?: string }[][] = []; + const access = toolAccessService(db, { composioAppToolkits: syncToolkits, remoteHttpRequest: async (_url, init) => { + const request = JSON.parse(String(init.body)); + const toolkits = request.params.arguments.toolkits; + expect(request.params.name).toBe("COMPOSIO_MANAGE_CONNECTIONS"); + batches.push(toolkits); + const results: Record = {}; + for (const toolkit of toolkits) { + operations.push(toolkit.action); + if (toolkit.action === "list") await onList(); + else if (mutationStatus !== 200) return new Response("unconfirmed", { status: mutationStatus }); + if (applyMutation && toolkit.name === "circleback_mcp") { + if (toolkit.action === "rename") accounts = accounts.map(account => account.id === toolkit.account_id ? { ...account, alias: toolkit.alias } : account); + if (toolkit.action === "remove") accounts = accounts.filter(account => account.id !== toolkit.account_id); + } + results[toolkit.name] = toolkit.name === failedToolkit ? { toolkit: toolkit.name, accounts: [{ status: "ACTIVE" }] } + : toolkit.action === "add" ? { redirect_url: "https://connect.composio.dev/link/test" } + : listResult ?? { toolkit: toolkit.name, accounts: toolkit.name === "circleback_mcp" ? accounts : [] }; + } + return Response.json({ jsonrpc: "2.0", id: request.id, result: { structuredContent: { data: { results } } } }); + } }); + return { company, agent, otherAgent, connection, catalog, access, operations, batches, accountId, actor: { actorType: "user" as const, actorId: userId }, + activate: () => { accounts = accounts.map(account => ({ ...account, status: "active" })); }, + disconnect: () => { accounts = []; }, + setListResult: (result: unknown) => { listResult = result; }, + failToolkit: (toolkit: string) => { failedToolkit = toolkit; }, + setMutationStatus: (status: number) => { mutationStatus = status; }, + refuseMutation: () => { applyMutation = false; }, + onList: (callback: () => Promise) => { onList = callback; } }; + + } + + async function configureAccess(f: Awaited>, agentId?: string) { + await f.access.putConnectionInstalls(f.connection.id, { installs: agentId + ? [{ targetType: "agent", targetId: agentId }] : [{ targetType: "company", targetId: f.company.id }] }, f.actor); + await f.access.finishGalleryAppConnection(f.company.id, f.connection.id, { + enabledCatalogEntryIds: f.catalog.map(tool => tool.id), + askFirstCatalogEntryIds: f.catalog.filter(tool => ["COMPOSIO_MULTI_EXECUTE_TOOL", "COMPOSIO_MANAGE_CONNECTIONS"].includes(tool.toolName)).map(tool => tool.id), + access: agentId ? { agentIds: [agentId] } : "all_agents", + }, f.actor); + } + + it("generates and verifies app sign-in without a task or new access grants, preserving company-wide gateway defaults", async () => { + const f = await fixture(); + await configureAccess(f); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id)); + const installs = await f.access.listConnectionInstalls(f.connection.id, f.company.id); + const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools; + expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toMatchObject({ status: "authorization_required" }); + expect(f.operations).toEqual(["list", "add"]); + expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0); + await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("Finish connecting"); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before); + f.activate(); + await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools.map(t => t.toolName).sort()).toEqual(f.catalog.map(t => t.toolName).sort()); + expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants); + expect(await f.access.listConnectionInstalls(f.connection.id, f.company.id)).toEqual(installs); + const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!; + const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } }); + expect(decision.decision).toBe("require_approval"); + expect(f.operations.filter(action => action === "add")).toHaveLength(1); + }); + it("does not create another link for an active account and rejects unknown toolkits", async () => { + const f = await fixture(); + f.activate(); + expect(await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor)).toEqual({ status: "connected" }); + expect(f.operations).toEqual(["list"]); + await expect(f.access.setupComposioApp(f.connection.id, "invented", { action: "start" }, f.actor)).rejects.toThrow("not in the catalog"); + }); + it("keeps meta-tool execution behind approval even if its catalog risk is read", async () => { + const f = await fixture(); + const tool = f.catalog.find(t => t.toolName === "COMPOSIO_MULTI_EXECUTE_TOOL")!; + await db.update(toolCatalogEntries).set({ riskLevel: "read" }).where(eq(toolCatalogEntries.id, tool.id)); + await configureAccess(f); + f.activate(); + await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor); + const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } }); + expect(decision.decision).toBe("require_approval"); + }); + it("does not complete app setup if the gateway is disabled while checking Composio", async () => { + const f = await fixture(); + f.activate(); + f.onList(async () => { await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.connection.id)); }); + await expect(f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor)).rejects.toThrow("no longer active"); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toHaveLength(0); + }); + it("preserves a saved gateway's restricted access when another app is connected", async () => { + const f = await fixture(); + await configureAccess(f, f.agent.id); + const before = (await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools; + f.activate(); + await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "complete" }, f.actor); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual(before); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools).toHaveLength(0); + }); + it("rejects agents, non-managers, and other companies before calling Composio", async () => { + const f = await fixture(); + const userId = randomUUID(); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: userId, membershipRole: "member", status: "active" }); + let providerCalls = 0; + const member: Express.Request["actor"] = { type: "board", userId, source: "session", isInstanceAdmin: false, companyIds: [f.company.id], memberships: [{ companyId: f.company.id, membershipRole: "member", status: "active" }] }; + const actors: [Express.Request["actor"], number][] = [ + [member, 403], + [{ ...member, companyIds: [randomUUID()], memberships: [] }, 404], + [{ type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_key" }, 403], + ]; + for (const [actor, status] of actors) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", toolAccessRoutes(db, { remoteHttpRequest: async () => { providerCalls++; throw new Error("Must not reach provider"); } })); + app.use(errorHandler); + await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/setup`).send({ action: "start" }).expect(status); + await request(app).get(`/api/tool-connections/${f.connection.id}/composio/apps`).expect(status); + await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/sync`).send({ force: true }).expect(status); + await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/refresh`).send({ toolkits: ["circleback_mcp"] }).expect(status); + await request(app).post(`/api/tool-connections/${f.connection.id}/composio/apps/circleback_mcp/accounts`).send({ action: "remove", accountId: f.accountId }).expect(status); + } + expect(providerCalls).toBe(0); + }); + it("persists safe observations per user and gateway, and refreshes known apps without creating accounts", async () => { + const f = await fixture(); + f.activate(); + const result = await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp", "hubspot"], f.actor); + expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ connectionId: f.connection.id, status: "connected", accounts: [{ id: f.accountId, alias: "Work", status: "ACTIVE" }] }); + expect(JSON.stringify(await db.select().from(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id)))).not.toContain("never-store-this-token"); + expect((await f.access.listComposioApps(f.connection.id, { ...f.actor, actorId: randomUUID() })).apps).toEqual([]); + f.operations.length = 0; + f.disconnect(); + const refreshed = await f.access.refreshComposioApps(f.connection.id, [], f.actor); + expect(f.operations).toEqual(["list"]); + expect(refreshed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] }); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]); + }); + it("rechecks apps from earlier direct setup and hides observations after a gateway identity changes", async () => { + const f = await fixture(); + f.activate(); + await f.access.setupComposioApp(f.connection.id, "circleback_mcp", { action: "start" }, f.actor); + await db.delete(toolConnectionAppSnapshots).where(eq(toolConnectionAppSnapshots.connectionId, f.connection.id)); + expect((await f.access.refreshComposioApps(f.connection.id, [], f.actor)).apps).toHaveLength(1); + await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/other-mcp" } }).where(eq(toolConnections.id, f.connection.id)); + expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]); + }); + async function finishSync(f: Awaited>) { + await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).not.toBe("syncing"), { timeout: 10_000 }); + return f.access.listComposioApps(f.connection.id, f.actor); + } + + it("discovers already connected apps across the catalog without a visible-page filter or access changes", async () => { + const f = await fixture(); f.activate(); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id)); + await f.access.syncComposioApps(f.connection.id, false, f.actor); + const result = await finishSync(f); + expect(result.sync).toMatchObject({ status: "ready", checked: 3, total: 3, failed: 0, coverage: "supported_catalog" }); + expect(result.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", errorAt: null }); + expect(f.batches.flat().map(tool => tool.name)).toEqual(["circleback_mcp", "notion", "hubspot"]); + expect(f.operations.every(action => action === "list")).toBe(true); + expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants); + expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0); + await f.access.syncComposioApps(f.connection.id, false, f.actor); + expect(f.operations).toHaveLength(3); // Fresh inventory does not start another scan. + f.disconnect(); + await f.access.syncComposioApps(f.connection.id, true, f.actor); + expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] }); + }); + + it("shares a lease between concurrent syncs and bounds provider batches", async () => { + const { COMPOSIO_APP_TOOLKITS } = await import("@paperclipai/shared/aggregator-app-catalog"); + const names = ["circleback_mcp", ...COMPOSIO_APP_TOOLKITS.filter(name => name !== "circleback_mcp").slice(0, 35)]; + const f = await fixture(names); f.activate(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + f.onList(() => held); + await Promise.all([f.access.syncComposioApps(f.connection.id, true, f.actor), f.access.syncComposioApps(f.connection.id, true, f.actor)]); + expect(f.batches).toHaveLength(1); + release(); + const result = await finishSync(f); + expect(result.sync).toMatchObject({ status: "ready", total: 36, checked: 36 }); + expect(f.batches.map(batch => batch.length)).toEqual([32, 4]); + expect(new Set(f.batches.flat().map(tool => tool.name)).size).toBe(36); + }); + + it("keeps failed account observations unverified and restores them after a successful sync", async () => { + const f = await fixture(); f.activate(); + await f.access.syncComposioApps(f.connection.id, true, f.actor); await finishSync(f); + f.setListResult({ accounts: [{ status: "ACTIVE" }] }); + await f.access.syncComposioApps(f.connection.id, true, f.actor); + const failed = await finishSync(f); + expect(failed.sync.status).toBe("error"); + expect(failed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "connected", accounts: [{ id: f.accountId }] }); + expect(failed.apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeTruthy(); + f.setListResult(undefined); + await f.access.syncComposioApps(f.connection.id, true, f.actor); + expect((await finishSync(f)).apps.find(app => app.toolkit === "circleback_mcp")?.errorAt).toBeNull(); + }); + + it("reports an incomplete sync when a toolkit without prior accounts fails", async () => { + const f = await fixture(); f.activate(); f.failToolkit("hubspot"); + await f.access.syncComposioApps(f.connection.id, true, f.actor); + const result = await finishSync(f); + expect(result.sync).toMatchObject({ status: "error", checked: 3, total: 3, failed: 1 }); + expect(result.sync.error).toBeTruthy(); + expect(result.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].id).toBe(f.accountId); + expect(result.apps.find(app => app.toolkit === "hubspot")).toBeUndefined(); + }); + + it("rejects observations when credentials change while a provider request is in flight", async () => { + const f = await fixture(); f.activate(); + f.onList(async () => { await db.update(toolConnections).set({ config: { ...f.connection.config, url: "https://connect.composio.dev/replaced" } }).where(eq(toolConnections.id, f.connection.id)); }); + await f.access.syncComposioApps(f.connection.id, true, f.actor); + await vi.waitFor(() => expect(f.operations.length).toBeGreaterThan(0)); + await vi.waitFor(async () => expect((await f.access.listComposioApps(f.connection.id, f.actor)).sync.status).toBe("idle")); + expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps).toEqual([]); + }); + + it("renames and removes only a verified account in the selected toolkit, and confirms both changes", async () => { + const f = await fixture(); f.activate(); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id)); + await expect(f.access.manageComposioAppAccount(f.connection.id, "hubspot", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("no longer available"); + expect(f.operations).toEqual(["list"]); + const renamed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor); + expect(renamed.apps.find(app => app.toolkit === "circleback_mcp")?.accounts[0].alias).toBe("Meetings"); + expect(f.batches.some(batch => batch.some(op => op.action === "rename" && op.account_id === f.accountId && op.alias === "Meetings"))).toBe(true); + const removed = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor); + expect(removed.apps.find(app => app.toolkit === "circleback_mcp")).toMatchObject({ status: "not_connected", accounts: [] }); + expect((await db.select().from(toolConnections).where(eq(toolConnections.id, f.connection.id)))[0]).toMatchObject({ status: "active", enabled: true }); + expect(await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connection.id))).toEqual(grants); + }); + it("adds another account without a task or an agent grant, even when an account is already active", async () => { + const f = await fixture(); f.activate(); + const result = await f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "add" }, f.actor); + expect(result.authorizationUrl).toBe("https://connect.composio.dev/link/test"); + expect(f.operations).toEqual(["add"]); + expect(await db.select().from(issues).where(eq(issues.companyId, f.company.id))).toHaveLength(0); + expect((await f.access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]); + }); + it("retains previous observations when a list result is incomplete or fails", async () => { + const f = await fixture(); f.activate(); + await f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor); + f.setListResult({ toolkit: "circleback_mcp", accounts: [{ status: "ACTIVE" }] }); + await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("complete account list"); + expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].id).toBe(f.accountId); + f.setListResult({ toolkit: "circleback_mcp", error: "expired", accounts: [] }); + await expect(f.access.refreshComposioApps(f.connection.id, ["circleback_mcp"], f.actor)).rejects.toThrow("could not check"); + expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].status).toBe("connected"); + }); + it("never retries an uncertain mutation or reports an unconfirmed rename as success", async () => { + const f = await fixture(); f.activate(); + f.setMutationStatus(400); + await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "remove", accountId: f.accountId }, f.actor)).rejects.toThrow("has not confirmed"); + expect(f.operations.filter(op => op === "remove")).toHaveLength(1); + f.setMutationStatus(200); f.refuseMutation(); + await expect(f.access.manageComposioAppAccount(f.connection.id, "circleback_mcp", { action: "rename", accountId: f.accountId, alias: "Meetings" }, f.actor)).rejects.toThrow("has not confirmed"); + expect((await f.access.listComposioApps(f.connection.id, f.actor)).apps[0].accounts[0].alias).toBe("Work"); + }); +}); diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index f7d4d71c8a..c0de02f88c 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -24,6 +24,8 @@ import { toolConnections, toolProfileBindings, toolProfiles, + toolPolicies, + toolProfileEntries, userSecretDefinitions, } from "@paperclipai/db"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; @@ -32,6 +34,8 @@ import { connectionIntentDeliveryService } from "../services/connection-intent-d import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; import { materializeNativeInteractionResponses } from "../services/native-runtime/native-interaction-bridge.js"; +import { toolAccessService } from "../services/tool-access.js"; +import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { getEmbeddedPostgresTestSupport, @@ -869,4 +873,105 @@ describeEmbeddedPostgres("connectionIntentService", () => { }); + async function accessFixture() { + const [company] = await db.insert(companies).values({ name: "Grant fixture", issuePrefix: `G${randomUUID().slice(0, 7)}` }).returning(); + const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Default agent", role: "general", adapterType: "process" }).returning(); + const [otherAgent] = await db.insert(agents).values({ companyId: company.id, name: "Other agent", role: "general", adapterType: "process" }).returning(); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "grant-user", membershipRole: "owner", status: "active" }); + const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Connect Circleback", status: "in_progress", assigneeAgentId: agent.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id, status: "running", responsibleUserId: "grant-user", contextSnapshot: { issueId: issue.id } }).returning(); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Composio", type: "mcp_http", metadata: { sourceTemplateKey: "composio" } }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: "Saved Composio", transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", enabled: true, status: "active", healthStatus: "ok", config: { sourceTemplateKey: "composio" } }).returning(); + await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active" }); + await db.insert(toolConnectionInstalls).values({ companyId: company.id, connectionId: connection.id, targetType: "company", targetId: company.id }); + const catalog = await db.insert(toolCatalogEntries).values([ + { toolName: "COMPOSIO_SEARCH_TOOLS", riskLevel: "read" as const }, + { toolName: "COMPOSIO_MANAGE_CONNECTIONS", riskLevel: "destructive" as const }, + { toolName: "COMPOSIO_REMOTE_BASH_TOOL", riskLevel: "destructive" as const }, + ].map(tool => ({ ...tool, companyId: company.id, connectionId: connection.id, name: tool.toolName, versionHash: "v1", entryKind: "tool" as const, status: "active" as const }))).returning(); + return { company, agent, otherAgent, issue, connection, catalog, service: connectionIntentService(db), claims: { ...claims, sub: agent.id, company_id: company.id, run_id: run.id, responsible_user_id: "grant-user" } }; + } + + it("creates a scoped access card and atomically accepts repeated clicks without granting other agents", async () => { + const f = await accessFixture(); + const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] }); + expect((await f.service.search(f.claims, "composio")).results[0].state).toBe("needs_user_action"); + const loaded = await f.service.loadIntent(request.interactionId!); + expect(loaded.interaction).toMatchObject({ title: "Grant Composio access to Default agent?", payload: { accessRequest: { connectionId: f.connection.id, tools: [ { toolName: "COMPOSIO_SEARCH_TOOLS", permission: "allowed" }, { toolName: "COMPOSIO_MANAGE_CONNECTIONS", permission: "ask_first" } ] } } }); + const repeated = await f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["COMPOSIO_MANAGE_CONNECTIONS", "COMPOSIO_SEARCH_TOOLS"] }); + expect(repeated.interactionId).toBe(request.interactionId); + await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user")).rejects.toThrow("connection-management authority"); + await expect(f.service.complete(request.interactionId!, f.connection.id, "other-user", { canManageOrganizationGrant: true })).rejects.toThrow("addressed user"); + const results = await Promise.all([1, 2].map(() => f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true }))); + expect(results.every(result => result.status === "accepted")).toBe(true); + const effective = await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id); + expect(effective.allowedTools.map(tool => tool.toolName).sort()).toEqual(["COMPOSIO_MANAGE_CONNECTIONS", "COMPOSIO_SEARCH_TOOLS"]); + expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.otherAgent.id)).allowedTools).toEqual([]); + for (const [name, outcome] of [["COMPOSIO_SEARCH_TOOLS", "allow"], ["COMPOSIO_MANAGE_CONNECTIONS", "require_approval"], ["COMPOSIO_REMOTE_BASH_TOOL", "deny"]]) { + const tool = f.catalog.find(tool => tool.toolName === name)!; + const decision = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} } }); + expect(decision.decision).toBe(outcome); + } + expect(await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, effective.profiles.find(profile => profile.profileKey.startsWith("connection-intent:"))!.id))).toHaveLength(2); + const managedTool = f.catalog[1]; + await db.insert(toolPolicies).values({ companyId: f.company.id, name: "New block after approval", policyType: "block", selectors: { catalogEntryId: managedTool.id }, priority: 100 }); + const blocked = await toolAccessPolicyService(db).decide({ companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, request: { connectionId: f.connection.id, catalogEntryId: managedTool.id, toolName: managedTool.toolName, arguments: {} } }); + expect(blocked.decision).toBe("deny"); + + }); + + it("rejects changed tools and rolls back installs, grants, and acceptance", async () => { + const f = await accessFixture(); + const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); + await db.update(toolCatalogEntries).set({ versionHash: "v2" }).where(eq(toolCatalogEntries.id, f.catalog[0].id)); + await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow("tools changed"); + expect((await f.service.loadIntent(request.interactionId!)).interaction.status).toBe("pending"); + expect((await toolAccessService(db).listConnectionInstalls(f.connection.id, f.company.id)).some(install => install.targetType === "agent")).toBe(false); + expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]); + const renewed = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); + expect(renewed.interactionId).not.toBe(request.interactionId); + expect((await f.service.loadIntent(request.interactionId!)).interaction.status).toBe("expired"); + expect((await f.service.loadIntent(renewed.interactionId!)).interaction.payload.accessRequest?.tools[0].versionHash).toBe("v2"); + }); + + it("revokes task-granted tools when the agent profile is removed, including Ask-first tools", async () => { + const f = await accessFixture(); + const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); + await f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true }); + const access = toolAccessService(db); + const effective = await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id); + const profile = effective.profiles.find(profile => profile.profileKey.startsWith("connection-intent:"))!; + await access.deleteProfile(profile.id, {}); + expect((await access.getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]); + for (const tool of f.catalog.slice(0, 2)) { + const decision = await toolAccessPolicyService(db).decide({ + companyId: f.company.id, actor: { actorType: "agent", actorId: f.agent.id, agentId: f.agent.id }, + request: { connectionId: f.connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} }, + }); + expect(decision.decision).toBe("deny"); + } + }); + + it("preserves explicit blocks and rejects unrelated or invented requested tools", async () => { + const f = await accessFixture(); + await expect(f.service.request(f.claims, "composio", { connectionId: randomUUID() })).rejects.toThrow("not eligible"); + await expect(f.service.request(f.claims, "composio", { connectionId: f.connection.id, toolNames: ["invented"] })).rejects.toThrow("active catalog"); + const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); + await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Explicit block", policyType: "block", selectors: { catalogEntryId: f.catalog[1].id } }); + await expect(f.service.complete(request.interactionId!, f.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow("existing policy blocks"); + await expect(f.service.request({ ...f.claims, company_id: randomUUID() }, "composio", { connectionId: f.connection.id })).rejects.toThrow(); + }); + + it("declines access without changing permissions and expires requests after reassignment", async () => { + const f = await accessFixture(); + const request = await f.service.request(f.claims, "composio", { connectionId: f.connection.id }); + await f.service.decline(request.interactionId!, "grant-user"); + expect((await toolAccessService(db).getEffectiveProfilesForAgent(f.company.id, f.agent.id)).allowedTools).toEqual([]); + const other = await accessFixture(); + const pending = await other.service.request(other.claims, "composio", { connectionId: other.connection.id }); + await db.update(issues).set({ assigneeAgentId: other.otherAgent.id }).where(eq(issues.id, other.issue.id)); + await expect(other.service.complete(pending.interactionId!, other.connection.id, "grant-user", { canManageOrganizationGrant: true })).rejects.toThrow(); + expect((await toolAccessService(db).getEffectiveProfilesForAgent(other.company.id, other.agent.id)).allowedTools).toEqual([]); + }); + }); diff --git a/server/src/routes/connection-intents.test.ts b/server/src/routes/connection-intents.test.ts index 9d80c00a55..9cb2e486f5 100644 --- a/server/src/routes/connection-intents.test.ts +++ b/server/src/routes/connection-intents.test.ts @@ -37,6 +37,8 @@ describe("runtime connection MCP contract", () => { type: "object", properties: { service: { type: "string" }, + connectionId: { type: "string", description: "Reuse this saved connection" }, + toolNames: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20, uniqueItems: true, description: "Exact indexed tools needed by this agent" }, targetService: { type: "string", description: "App slug returned by search only when the user explicitly named this external provider" }, selectionInteractionId: { type: "string", diff --git a/server/src/routes/connection-intents.ts b/server/src/routes/connection-intents.ts index 9d53c87ec9..e3334ba6a3 100644 --- a/server/src/routes/connection-intents.ts +++ b/server/src/routes/connection-intents.ts @@ -109,7 +109,7 @@ export function runtimeConnectionIntentRoutes(db: Db) { } if (name === "connection_request") { const input = connectionRequestInputSchema.parse(params.arguments ?? {}); - const result = await service.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService }); + const result = await service.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService, connectionId: input.connectionId, toolNames: input.toolNames }); res.json({ jsonrpc: "2.0", id, result: resultContent(result) }); return; } @@ -133,7 +133,7 @@ export function runtimeConnectionIntentRoutes(db: Db) { }); router.post("/runtime-tools/connections/request", async (req, res) => { const input = connectionRequestInputSchema.parse(req.body ?? {}); - res.json(await service.request(runtimeClaims(req), input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService })); + res.json(await service.request(runtimeClaims(req), input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService, connectionId: input.connectionId, toolNames: input.toolNames })); }); return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index dfda2d4d57..be92ac52f4 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -303,6 +303,8 @@ import { replaceChatEndpointResourcesSchema, updateChatEndpointSchema, } from "@paperclipai/shared"; +import { aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema } from "@paperclipai/shared/aggregator-apps"; +import { composioAppsSyncSchema, composioAppsRefreshSchema, composioAppSetupSchema, composioAppAccountSchema } from "@paperclipai/shared/composio-app-setup"; import { COMPANY_IMPORT_TRANSFERS_API_PATH, companyImportTransferDeclarationSchema, @@ -1454,6 +1456,15 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/tool-connections/{connectionId}/railway/ssh", "POST /api/tool-connections/{connectionId}/catalog/refresh", "GET /api/tool-connections/{connectionId}/catalog", + "GET /api/tool-connections/{connectionId}/aggregator/apps", + "POST /api/tool-connections/{connectionId}/aggregator/apps/sync", + "POST /api/tool-connections/{connectionId}/aggregator/apps/refresh", + "PUT /api/tool-connections/{connectionId}/aggregator/discovery", + "GET /api/tool-connections/{connectionId}/composio/apps", + "POST /api/tool-connections/{connectionId}/composio/apps/sync", + "POST /api/tool-connections/{connectionId}/composio/apps/refresh", + "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup", + "POST /api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts", "GET /api/tool-connections/{connectionId}/activity", "GET /api/tool-connections/{connectionId}/test-agents", "GET /api/tool-connections/{connectionId}/test-agents/{agentId}/access", @@ -10774,6 +10785,38 @@ registerCurrentRoute({ summary: "List tool connection activity", }); +for (const provider of ["aggregator", "composio"] as const) { + registerCurrentRoute({ + method: "get", path: `/api/tool-connections/{connectionId}/${provider}/apps`, tags: ["tool-access"], + summary: "List upstream account observations for the current connection manager", + }); + registerCurrentRoute({ + method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/sync`, tags: ["tool-access"], + summary: "Start upstream account discovery without changing tool access", + body: provider === "aggregator" ? aggregatorAppsSyncSchema : composioAppsSyncSchema, + }); + registerCurrentRoute({ + method: "post", path: `/api/tool-connections/{connectionId}/${provider}/apps/refresh`, tags: ["tool-access"], + summary: "Refresh upstream account observations", + body: provider === "aggregator" ? aggregatorAppsRefreshSchema : composioAppsRefreshSchema, + }); +} +registerCurrentRoute({ + method: "put", path: "/api/tool-connections/{connectionId}/aggregator/discovery", tags: ["tool-access"], + summary: "Save manager-owned optional Arcade account discovery credentials", + body: arcadeDiscoverySetupSchema, +}); +registerCurrentRoute({ + method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/setup", tags: ["tool-access"], + summary: "Start or verify Composio app authorization through a saved gateway", + body: composioAppSetupSchema, +}); +registerCurrentRoute({ + method: "post", path: "/api/tool-connections/{connectionId}/composio/apps/{toolkit}/accounts", tags: ["tool-access"], + summary: "Manage a Composio account through a saved gateway", + body: composioAppAccountSchema, +}); + registerCurrentRoute({ method: "get", path: "/api/tool-connections/{connectionId}/test-agents", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index ecf346118b..12015d2197 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,3 +1,5 @@ +import { composioAppSetupSchema, composioAppsRefreshSchema, composioAppsSyncSchema, composioAppAccountSchema } from "@paperclipai/shared"; +import { aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema } from "@paperclipai/shared/aggregator-apps"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { agents, companies, connectionGrants, issueThreadInteractions, toolConnectionInstalls } from "@paperclipai/db"; @@ -2032,6 +2034,68 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json({ access: accessSummary }); }); + async function composioAppManager(req: Request, res: Response) { + assertBoard(req); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return null; + if (!await isToolConnectionManager(req, connection.companyId)) throw forbidden("Only a connection manager can configure apps and grant agent access"); + return connection; + } + + router.get("/tool-connections/:connectionId/aggregator/apps", async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.set("Cache-Control", "private, no-store"); + res.json(await svc.listAggregatorApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + router.post("/tool-connections/:connectionId/aggregator/apps/sync", validate(aggregatorAppsSyncSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.syncAggregatorApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + router.post("/tool-connections/:connectionId/aggregator/apps/refresh", validate(aggregatorAppsRefreshSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.refreshAggregatorApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + router.put("/tool-connections/:connectionId/aggregator/discovery", validate(arcadeDiscoverySetupSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.configureArcadeDiscovery(connection.id, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + + router.get("/tool-connections/:connectionId/composio/apps", async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.listComposioApps(connection.id, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + + router.post("/tool-connections/:connectionId/composio/apps/sync", validate(composioAppsSyncSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.syncComposioApps(connection.id, req.body.force, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + + router.post("/tool-connections/:connectionId/composio/apps/refresh", validate(composioAppsRefreshSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.refreshComposioApps(connection.id, req.body.toolkits, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + + router.post("/tool-connections/:connectionId/composio/apps/:toolkit/accounts", validate(composioAppAccountSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + res.json(await svc.manageComposioAppAccount(connection.id, req.params.toolkit as string, req.body, { actorType: "user", actorId: req.actor.userId ?? "board" })); + }); + + router.post("/tool-connections/:connectionId/composio/apps/:toolkit/setup", validate(composioAppSetupSchema), async (req, res) => { + const connection = await composioAppManager(req, res); + if (!connection) return; + const result = await svc.setupComposioApp(connection.id, req.params.toolkit as string, req.body, + { actorType: "user", actorId: req.actor.userId ?? "board" }); + res.json(result); + }); + router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => { assertBoard(req); if (!options.toolGateway) { diff --git a/server/src/services/aggregator-app-discovery.ts b/server/src/services/aggregator-app-discovery.ts new file mode 100644 index 0000000000..6df68bd81b --- /dev/null +++ b/server/src/services/aggregator-app-discovery.ts @@ -0,0 +1,142 @@ +import type { ComposioAppAccount } from "@paperclipai/shared"; +import { aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps"; +import { resolveAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog"; + +export class AggregatorDiscoveryUnavailableError extends Error {} + +export type DiscoveredApp = { toolkit: string; accounts: ComposioAppAccount[] }; +export type JsonRequest = (path: string) => Promise; +export type McpCall = (name: string, args: Record) => Promise; +const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const string = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value.trim() : undefined; + +/** Read only explicit structured/JSON result channels, never provider prose. */ +export function inventoryPayload(value: unknown, depth = 0): Record { + if (depth > 8) throw new Error("Malformed provider inventory"); + const root = record(value); + if (root.error || root.isError) throw new Error("Provider account discovery failed"); + if (root.result !== undefined) return inventoryPayload(root.result, depth + 1); + if (root.structuredContent !== undefined) return inventoryPayload(root.structuredContent, depth + 1); + if (root.value !== undefined && !root.items && !root.connections) return inventoryPayload(root.value, depth + 1); + if (Array.isArray(root.content)) { + for (const block of root.content) { + if (record(block).type !== "text") continue; + try { return inventoryPayload(JSON.parse(String(record(block).text)), depth + 1); } catch { /* Try another JSON block. */ } + } + throw new Error("Provider did not return an account inventory"); + } + return root; +} + +async function pages(request: JsonRequest, path: string): Promise[]> { + const result: Record[] = []; + let offset = 0; + for (let pageIndex = 0; pageIndex < 100; pageIndex++) { + const page = record(await request(`${path}${path.includes("?") ? "&" : "?"}limit=100&offset=${offset}`)); + if (!Array.isArray(page.items) || page.items.some(item => !item || typeof item !== "object")) throw new Error("Incomplete provider inventory"); + result.push(...page.items.map(record)); + const total = typeof page.total_count === "number" ? page.total_count : typeof page.total === "number" ? page.total : undefined; + if (total !== undefined && (!Number.isSafeInteger(total) || total < 0)) throw new Error("Incomplete provider inventory"); + if (total !== undefined && result.length >= total) return result; + // Arcade returns the next offset, which can advance after a short page. + if (page.offset !== undefined) { + if (page.offset === 0 && total === undefined) return result; + if (!page.items.length || !Number.isSafeInteger(page.offset) || Number(page.offset) <= offset) throw new Error("Incomplete provider inventory"); + offset = Number(page.offset); + } else { + if (page.items.length < 100) { + if (total !== undefined) throw new Error("Incomplete provider inventory"); + return result; + } + offset += 100; + } + } + throw new Error("Provider inventory exceeded the discovery limit"); +} + +export async function discoverArcadeApps(input: { request: JsonRequest; userId: string; gatewayTools: string[] }): Promise { + const [accounts, tools] = await Promise.all([ + pages(input.request, `/v1/admin/user_connections?user[id]=${encodeURIComponent(input.userId)}`), + pages(input.request, `/v1/tools?user_id=${encodeURIComponent(input.userId)}`), + ]); + const exposed = new Set(input.gatewayTools); + const grouped = new Map>(); + for (const tool of tools) { + const toolName = string(tool.qualified_name) ?? string(tool.name); + const names = [toolName, string(tool.fully_qualified_name), string(tool.name)].filter((name): name is string => Boolean(name)); + if (!toolName || !names.some(name => exposed.has(name) || exposed.has(name.replaceAll(".", "_")))) continue; + const toolkit = string(record(tool.toolkit).name) ?? toolName.split(/[._]/)[0]; + const requirements = record(tool.requirements); + const auth = record(requirements.authorization); + const providerId = string(auth.provider_id); + if (!providerId || !toolkit) continue; + const app = resolveAggregatorApp("arcade", toolkit); + for (const account of accounts) { + // Admin credentials can enumerate other users; reject unscoped records. + if (account.user_id !== input.userId || account.provider_id !== providerId) continue; + const id = string(account.id) ?? string(account.connection_id); + if (!id) throw new Error("Malformed Arcade account"); + const live = ["active", "connected"].includes(String(account.connection_status).toLowerCase()); + const status = live && requirements.met === true && auth.token_status === "completed" ? "ACTIVE" + : auth.token_status === "pending" ? "INITIATED" : auth.token_status === "failed" ? "EXPIRED" : "UNVERIFIED"; + const info = record(account.provider_user_info); + const existing = grouped.get(toolkit) ?? new Map(); + if (existing.get(id)?.status !== "ACTIVE") existing.set(id, { + id, alias: string(info.email) ?? string(info.name) ?? null, status, isDefault: false, + appSlug: app.slug, appName: app.name, managementUrl: aggregatorManagementUrl("arcade"), + }); + grouped.set(toolkit, existing); + } + } + return [...grouped].map(([toolkit, accounts]) => ({ toolkit, accounts: [...accounts.values()] })); +} + +export const EXECUTOR_INVENTORY_CODE = "if (typeof tools === 'undefined' || typeof tools.executor?.coreTools?.connections?.list !== 'function' || typeof tools.executor?.coreTools?.integrations?.list !== 'function') return { discoveryUnavailable: true }; const connections = await tools.executor.coreTools.connections.list({}); const integrations = await tools.executor.coreTools.integrations.list({}); const managementUrls = {}; for (const integration of new Set(connections.connections.map(account => account.integration))) { try { const handoff = await tools.executor.coreTools.connections.createHandoff({ integration }); managementUrls[integration] = handoff.url; } catch {} } return { connections: connections.connections, integrations: integrations.integrations, managementUrls };"; + +export async function discoverExecutorApps(input: { call: McpCall; toolNames: string[]; managementUrl?: string | null }): Promise { + const accounts: Record[] = []; + const metadata = new Map>(); + let managementUrls: Record = {}; + if (input.toolNames.includes("integrations")) { + let offset = 0; + for (let pageIndex = 0; pageIndex < 200; pageIndex++) { + const page = inventoryPayload(await input.call("integrations", { limit: 50, offset })); + if (!Array.isArray(page.items) || typeof page.hasMore !== "boolean") throw new Error("Incomplete Executor inventory"); + accounts.push(...page.items.map(record)); + if (!page.hasMore) break; + if (typeof page.nextOffset !== "number" || page.nextOffset <= offset || pageIndex === 199) throw new Error("Incomplete Executor inventory"); + offset = page.nextOffset; + } + } else if (input.toolNames.includes("execute")) { + const payload = inventoryPayload(await input.call("execute", { code: EXECUTOR_INVENTORY_CODE })); + if (!Array.isArray(payload.connections) || !Array.isArray(payload.integrations)) throw new AggregatorDiscoveryUnavailableError("Executor account discovery is unavailable on this server"); + accounts.push(...payload.connections.map(record)); + managementUrls = record(payload.managementUrls); + for (const item of payload.integrations) { const row = record(item); if (string(row.slug)) metadata.set(String(row.slug), row); } + } else throw new AggregatorDiscoveryUnavailableError("Executor account discovery is unavailable on this server"); + const grouped = new Map(); + for (const account of accounts) { + const toolkit = string(account.integration); + const name = string(account.connection) ?? string(account.name); + const owner = string(account.owner); + if (!toolkit || !name || !owner) throw new Error("Malformed Executor account"); + const app = resolveAggregatorApp("executor", toolkit, string(account.integrationName) ?? string(metadata.get(toolkit)?.name)); + const health = record(account.lastHealth); + const status = health.status === "healthy" || health.status === "ok" ? "ACTIVE" + : health.status === "unhealthy" || health.status === "expired" || health.status === "dead" ? "EXPIRED" : "UNVERIFIED"; + let managementUrl = aggregatorManagementUrl("executor", input.managementUrl); + if (!managementUrl && typeof managementUrls[toolkit] === "string") { + managementUrl = aggregatorManagementUrl("executor", managementUrls[toolkit] as string); + if (managementUrl) { const url = new URL(managementUrl); url.search = ""; url.hash = ""; managementUrl = url.toString(); } + } + const id = JSON.stringify([toolkit, owner, name]); + const before = grouped.get(toolkit) ?? []; + if (before.some(item => item.id === id)) throw new Error("Duplicate Executor account identity"); + before.push({ id, alias: string(account.identityLabel) ?? name, status, isDefault: false, + appSlug: app.slug, appName: app.name, healthCheckedAt: typeof health.checkedAt === "number" && Number.isFinite(health.checkedAt) && Math.abs(health.checkedAt) <= 8.64e15 ? new Date(health.checkedAt).toISOString() : string(health.checkedAt) ?? null, + managementUrl, + }); + grouped.set(toolkit, before); + } + return [...grouped].map(([toolkit, accounts]) => ({ toolkit, accounts })); +} diff --git a/server/src/services/composio-app-setup.ts b/server/src/services/composio-app-setup.ts new file mode 100644 index 0000000000..bbb91409ab --- /dev/null +++ b/server/src/services/composio-app-setup.ts @@ -0,0 +1,78 @@ +import type { ComposioAppAccount, ComposioAppSetupResult } from "@paperclipai/shared"; +import { extractRemoteMcpPending } from "./remote-mcp-pending.js"; +import { unprocessable } from "../errors.js"; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function payloadsFrom(response: unknown) { + const root = record(response); + const result = record(root?.result) ?? root; + const payloads = [result, record(result?.structuredContent)]; + if (Array.isArray(result?.content)) { + for (const item of result.content) { + const block = record(item); + if (block?.type !== "text" || typeof block.text !== "string") continue; + try { payloads.push(record(JSON.parse(block.text))); } catch { /* Plain text is not proof. */ } + } + } + if (root?.error || result?.isError === true || payloads.some(p => p?.successful === false || p?.success === false || p?.error)) { + throw unprocessable("Composio could not configure this app. Check the app in Composio and try again."); + } + return payloads; +} + +/** Retain only account identity and status, never raw provider results or credentials. */ +export function composioAppAccounts(response: unknown, toolkit: string): ComposioAppAccount[] | undefined { + const accounts = new Map(); + let observed = false; + let incomplete = false; + let visited = 0; + function visit(value: unknown, matched: boolean, depth: number) { + if (++visited > 20_000 || depth > 12) { incomplete = true; return; } + if (Array.isArray(value)) { value.forEach(item => visit(item, matched, depth + 1)); return; } + const entry = record(value); + if (!entry) return; + const named = entry.toolkit ?? entry.toolkit_name ?? entry.toolkit_slug; + const selected = matched || named === toolkit || record(named)?.slug === toolkit; + if (selected && (entry.error || entry.success === false || entry.successful === false)) { + throw unprocessable("Composio could not check this app. Refresh its accounts before trying again."); + } + if (selected && Array.isArray(entry.accounts)) { + observed = true; + for (const item of entry.accounts) { + const account = record(item); + const id = account?.id ?? account?.account_id ?? account?.connected_account_id; + if (typeof id !== "string" || !id || id.length > 200) { incomplete = true; continue; } + accounts.set(id, { id, alias: typeof account?.alias === "string" ? account.alias.slice(0, 100) : null, + status: typeof account?.status === "string" ? account.status.toUpperCase().slice(0, 40) : "UNKNOWN", + isDefault: account?.is_default === true || account?.isDefault === true }); + } + } + for (const [key, child] of Object.entries(entry)) visit(child, selected || key === toolkit, depth + 1); + } + payloadsFrom(response).forEach(payload => visit(payload, false, 0)); + return observed && !incomplete ? [...accounts.values()] : undefined; +} + +/** Only the requested toolkit can establish success; other accounts are unrelated. */ +export function composioAppSetupResult(response: unknown, toolkit: string): ComposioAppSetupResult { + const payloads = payloadsFrom(response); + let connected = false; + let visited = 0; + const visit = (value: unknown, matched: boolean, depth: number) => { + if (++visited > 20_000 || depth > 12) return; + if (Array.isArray(value)) { value.forEach(item => visit(item, matched, depth + 1)); return; } + const entry = record(value); + if (!entry) return; + const named = entry.toolkit ?? entry.toolkit_name ?? entry.toolkit_slug ?? entry.name; + const selected = matched || named === toolkit || record(named)?.slug === toolkit; + if (selected && (typeof entry.status === "string" && entry.status.toUpperCase() === "ACTIVE" || entry.connected === true)) connected = true; + for (const [key, child] of Object.entries(entry)) visit(child, selected || key === toolkit, depth + 1); + }; + payloads.forEach(payload => visit(payload, false, 0)); + if (connected) return { status: "connected" }; + const url = extractRemoteMcpPending(response, "composio", "COMPOSIO_MANAGE_CONNECTIONS")?.links[0]?.url; + return url ? { status: "authorization_required", authorizationUrl: url } : { status: "not_connected" }; +} diff --git a/server/src/services/connection-agent-access.ts b/server/src/services/connection-agent-access.ts new file mode 100644 index 0000000000..1765abec7e --- /dev/null +++ b/server/src/services/connection-agent-access.ts @@ -0,0 +1,73 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { toolCatalogEntries, toolConnections, toolProfiles, toolProfileEntries, toolPolicies, toolProfileBindings } from "@paperclipai/db"; +import type { ConnectionIntentInteraction } from "@paperclipai/shared"; +import { conflict } from "../errors.js"; +import { toolAccessPolicyService } from "./tool-access-policy.js"; +import { logActivity } from "./activity-log.js"; + +type AccessTools = NonNullable["tools"]; + +/** Called inside the caller's transaction after identity and installation checks. */ +export async function grantConnectionAgentTools(db: Db, input: { + connection: Pick; + agentId: string; + userId: string; + tools: AccessTools; + interactionId?: string; + context?: { issueId: string; projectId: string | null }; +}) { + const { connection, agentId, userId, tools, interactionId, context } = input; + const catalog = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), + )).for("update"); + for (const requested of tools) { + const tool = catalog.find(entry => entry.id === requested.catalogEntryId); + if (!tool || tool.status !== "active" || tool.entryKind !== "tool" || tool.toolName !== requested.toolName + || tool.versionHash !== requested.versionHash || (requested.permission === "allowed" && tool.riskLevel !== "read")) { + throw conflict("The requested tools changed. Ask the agent for a new access request."); + } + const decision = await toolAccessPolicyService(db).decide({ + companyId: connection.companyId, + actor: { actorType: "agent", actorId: agentId, agentId: agentId }, + runContext: context ?? {}, + request: { connectionId: connection.id, catalogEntryId: tool.id, toolName: tool.toolName, arguments: {} }, + }); + if (decision.decision === "deny" && decision.reasonCode !== "deny_default") { + throw conflict("An existing policy blocks this tool. Review the connection's permissions before granting access."); + } + } + // An additive, agent-scoped profile leaves other connection permissions + // and company-wide assignments intact. The caller supplies the exact reviewed tool set. + const profileKey = `connection-intent:${connection.id}:${agentId}`; + const [profile] = await db.insert(toolProfiles).values({ + companyId: connection.companyId, profileKey, name: profileKey, defaultAction: "deny", + metadata: { source: "connection_intent", connectionId: connection.id, agentId: agentId }, + }).onConflictDoUpdate({ target: [toolProfiles.companyId, toolProfiles.profileKey], set: { status: "active", updatedAt: new Date() } }).returning(); + const entries = await db.select().from(toolProfileEntries).where(and( + eq(toolProfileEntries.companyId, connection.companyId), eq(toolProfileEntries.profileId, profile.id), + )); + for (const tool of tools) { + if (!entries.some(entry => entry.catalogEntryId === tool.catalogEntryId && entry.effect === "include")) { + await db.insert(toolProfileEntries).values({ companyId: connection.companyId, profileId: profile.id, + selectorType: "catalog_entry", effect: "include", catalogEntryId: tool.catalogEntryId, + connectionId: connection.id, applicationId: connection.applicationId }); + } + if (tool.permission === "ask_first") { + const name = `connection-intent:${agentId}:${tool.catalogEntryId}`; + await db.insert(toolPolicies).values({ companyId: connection.companyId, name, policyType: "require_approval", priority: 0, + selectors: { connectionId: connection.id, catalogEntryId: tool.catalogEntryId }, + conditions: { actor: { agentId: agentId } }, + config: { source: "connection_intent", connectionId: connection.id, agentId: agentId }, + createdByUserId: userId, + }).onConflictDoUpdate({ target: [toolPolicies.companyId, toolPolicies.name], set: { enabled: true, updatedAt: new Date() } }); + } + } + await db.insert(toolProfileBindings).values({ companyId: connection.companyId, profileId: profile.id, + targetType: "agent", targetId: agentId, priority: 100, createdByUserId: userId, + metadata: { source: "connection_intent", connectionId: connection.id }, + }).onConflictDoNothing(); + await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: userId, + action: "tool_connection.agent_access_granted", entityType: "tool_connection", entityId: connection.id, + details: { ...(interactionId ? { interactionId } : {}), agentId, profileId: profile.id, tools: tools } }); +} diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 34cec954f7..d369ad33cc 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -1,6 +1,8 @@ import { emailChannelService } from "./email-channels.js"; import { emailConnectionService } from "./email-connections.js"; +import { grantConnectionAgentTools } from "./connection-agent-access.js"; import { agentService } from "./agents.js"; +import { createHash } from "node:crypto"; import { logActivity } from "./activity-log.js"; import { aiConnectionService } from "./ai-connections.js"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; @@ -12,6 +14,10 @@ import { companies, toolConnections, toolCatalogEntries, + toolProfiles, + toolProfileEntries, + toolProfileBindings, + toolPolicies, companyMemberships, heartbeatRuns, issueThreadInteractions, @@ -44,6 +50,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { toolAccessService } from "./tool-access.js"; +import { toolAccessPolicyService } from "./tool-access-policy.js"; import { captureRunIdentity } from "./run-identity.js"; import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; @@ -431,9 +438,9 @@ export function connectionIntentService(db: Db) { candidates.push({ score, nameScore, item: { service, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null, methods: app.methods, source: app.source, - state: ready ? "ready" : denied ? "unavailable" : !app.available || !app.methods.length ? "unavailable" + state: ready ? "ready" : (denied && !isRemoteMcpConnectorId(service)) || !app.available || !app.methods.length ? "unavailable" : matching.length ? "needs_user_action" : "available", - reason: ready ? "Connection is installed and usable by this agent" : denied ? "An administrator has not permitted executable tools for this agent; reconnecting cannot grant that permission" : !app.available ? "Connection is disabled or unavailable" + reason: ready ? "Connection is installed and usable by this agent" : denied ? "Ask the responsible user to grant this agent access with connection_request" : !app.available ? "Connection is disabled or unavailable" : matching.some((connection) => isToolConnectionAttentionHealth(connection.healthStatus)) ? "Connection needs attention" : matching.length ? "Review identity and access for this agent" : "Connect this service to continue", connectionId: ready?.id ?? null, @@ -639,7 +646,7 @@ export function connectionIntentService(db: Db) { async function request( claims: ConnectionRunClaims, serviceSlug: string, - options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string } = {}, + options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string; connectionId?: string; toolNames?: string[] } = {}, ): Promise { const context = await loadRunContext(claims); return requestWithContext(context, serviceSlug, options); @@ -648,7 +655,7 @@ export function connectionIntentService(db: Db) { async function requestWithContext( context: Awaited>, serviceSlug: string, - options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string } = {}, + options: { purpose?: "ai" | "channel"; selectionInteractionId?: string; targetService?: string; connectionId?: string; toolNames?: string[] } = {}, ): Promise { const claims = { sub: context.agent.id, company_id: context.run.companyId, run_id: context.run.id, responsible_user_id: context.run.responsibleUserId! }; const route = parseAggregatorRoute(serviceSlug); @@ -660,7 +667,7 @@ export function connectionIntentService(db: Db) { if (!selected?.aggregator) throw forbidden("The requested provider cannot connect this app without verified support and a recorded user choice or explicit user request"); // Reuse the saved-answer validation below; a pending question also carries // an interaction ID, but is never permission to create the setup card. - if (selected.service !== serviceSlug) return request(claims, selected.service, { selectionInteractionId: found.selectionInteractionId }); + if (selected.service !== serviceSlug) return request(claims, selected.service, { ...options, targetService: undefined, selectionInteractionId: found.selectionInteractionId }); upstreamService = { slug: selected.aggregator.targetService, name: selected.aggregator.targetName }; } if (route) { @@ -684,6 +691,41 @@ export function connectionIntentService(db: Db) { if (!app.available || app.methods.length === 0) { throw unprocessable(`Connection service ${serviceSlug} is not available`); } + let accessRequest: ConnectionIntentInteraction["payload"]["accessRequest"]; + if (!options.purpose) { + const inventory = await connectionInventory(context.run.companyId); + const matching = inventory.connections.filter(connection => + sourceSlugForConnection(connection, inventory.applicationsById) === app.slug + && (!options.connectionId || connection.id === options.connectionId) + && connection.connectionPurpose !== "ai" && connection.status === "active" && connection.enabled + && !isToolConnectionAttentionHealth(connection.healthStatus)); + const eligible = (await Promise.all(matching.map(async connection => { + const { grants } = await access.listConnectionGrants(connection.id, context.run.companyId); + return grants.some(grant => grant.status === "active" && ( + grant.kind === "organization" || grant.subjectUserId === context.run.responsibleUserId + || (grant.kind === "agent" && grant.subjectAgentId === context.agent.id))) ? connection : null; + }))).filter((connection): connection is ToolConnection => Boolean(connection)); + if (options.connectionId && !eligible.length) throw notFound("The saved connection is not eligible for this request"); + if (eligible.length === 1) { + const connection = eligible[0]!; + const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool"); + const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName)); + const tools = names.map(name => catalog.find(tool => tool.toolName === name)); + if (options.toolNames && tools.some(tool => !tool)) throw unprocessable("A requested tool is not in this connection's active catalog"); + const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id); + const installed = effective.installedConnections.some(item => item.id === connection.id); + const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id)); + if (missing && tools.length && tools.every(tool => Boolean(tool))) { + if (tools.length > 20) throw unprocessable("Specify the tools needed for this task (up to 20)"); + accessRequest = { + connectionId: connection.id, connectionName: connection.name, + tools: tools.map(tool => ({ catalogEntryId: tool!.id, toolName: tool!.toolName, versionHash: tool!.versionHash, + permission: tool!.riskLevel === "read" ? "allowed" as const : "ask_first" as const })) + .sort((a, b) => Number(a.permission === "ask_first") - Number(b.permission === "ask_first") || a.toolName.localeCompare(b.toolName)), + }; + } + } + } const ready = await usableConnectionForAgent({ companyId: context.run.companyId, agentId: context.agent.id, @@ -691,7 +733,7 @@ export function connectionIntentService(db: Db) { serviceSlug: app.slug, purpose: options.purpose, }); - if (ready) { + if (ready && !accessRequest && (!options.connectionId || ready.id === options.connectionId)) { return { version: 1, service: app.slug, @@ -701,7 +743,9 @@ export function connectionIntentService(db: Db) { instruction: isRemoteMcpConnectorId(app.slug) ? aggregatorContinuationInstruction(app.slug, upstreamService?.name ?? "The requested app") : options.purpose === "channel" ? "An active AgentMail inbox is assigned to you. Use agentmail_inboxes to read its address; do not request another connection." : options.purpose === "ai" ? `${app.name} authentication is available for the next execution.` : `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`, }; } - if (!options.purpose && await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) { + // Missing profile entries can be reviewed in a scoped access card. Acceptance + // still revalidates every tool and refuses explicit policy denials. + if (!options.purpose && !accessRequest && await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) { throw forbidden("This agent has no permitted actions for this service. Ask an administrator to review tool permissions; reconnecting will not remove a denial."); } const outcomeId = context.run.contextSnapshot?.interactionId; @@ -716,9 +760,10 @@ export function connectionIntentService(db: Db) { { payload: { version: 1, + ...(accessRequest ? { accessRequest } : {}), serviceSlug: app.slug, ...(options.purpose ? { purpose: options.purpose } : {}), - serviceName: upstreamService ? `${upstreamService.name} through ${app.name}` : app.name, + serviceName: accessRequest ? app.name : upstreamService ? `${upstreamService.name} through ${app.name}` : app.name, ...(upstreamService ? { upstreamService } : {}), serviceLogoUrl: app.branding.logoUrl ?? null, serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null, @@ -729,7 +774,7 @@ export function connectionIntentService(db: Db) { sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId, addresseeUserId: context.run.responsibleUserId!, - idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${upstreamService ? `:${upstreamService.slug}` : ""}${options.purpose ? `:${options.purpose}` : ""}`, + idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${upstreamService ? `:${upstreamService.slug}` : ""}${options.purpose ? `:${options.purpose}` : ""}${accessRequest ? `:access:${createHash("sha256").update(JSON.stringify(accessRequest)).digest("hex")}` : ""}`, }, ); if (interaction.status !== "pending") throw conflict("This connection request has already been resolved. Follow its recorded outcome."); @@ -853,6 +898,7 @@ export function connectionIntentService(db: Db) { id, applicationId, name, status, enabled, })), requestedAgentId: payload.requestingAgentId, + canGrantAccess: payload.accessRequest ? options.canManageOrganizationGrant === true : undefined, aiConnection: managed?.binding, aiConnectionRequiresAdoption: managed?.requiresAdoption || undefined, aiRepair: selectedAiAccount ? { @@ -903,6 +949,15 @@ export function connectionIntentService(db: Db) { const txDb = tx as unknown as Db; const txAccess = toolAccessService(txDb); const txInteractions = issueThreadInteractionService(txDb); + const current = await txInteractions.getForIssue(task, interactionId) as ConnectionIntentInteraction; + if (current.status !== "pending") { + if (current.status === "accepted" && current.result?.connectionId === connectionId) return current; + throw conflict("Connection intent is already resolved"); + } + if (payload.accessRequest && (payload.accessRequest.connectionId !== connectionId || !options.canManageOrganizationGrant)) { + if (payload.accessRequest.connectionId !== connectionId) throw conflict("Use the connection named in this access request"); + throw forbidden("Granting agent tool access requires connection-management authority"); + } await tx.select({ id: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, loaded.issue.companyId))).for("update"); let selectedConnection = await txAccess.getConnection(connectionId, loaded.issue.companyId); const selectedApplication = await txAccess.getApplication( @@ -979,7 +1034,7 @@ export function connectionIntentService(db: Db) { const pendingPersonalGrant = grants.find((grant) => grant.kind === "user" && grant.status === "active" && grant.subjectUserId === userId ); - if (selectedConnection.authKind === "oauth" && pendingPersonalGrant) { + if (!payload.accessRequest && selectedConnection.authKind === "oauth" && pendingPersonalGrant) { // txAccess is bound to the outer transaction. Its internal transactions // become savepoints, so activation, credential bindings, and the // requesting agent's access roll back with any later failure. @@ -1035,6 +1090,14 @@ export function connectionIntentService(db: Db) { actorId: userId, }); + if (payload.accessRequest) { + await grantConnectionAgentTools(txDb, { + connection: selectedConnection, agentId: payload.requestingAgentId, userId, + tools: payload.accessRequest.tools, interactionId, + context: { issueId: task.id, projectId: task.projectId }, + }); + } + const effective = await txAccess.getEffectiveProfilesForAgent(loaded.issue.companyId, payload.requestingAgentId); if (!effective.allowedTools.some((tool) => tool.connectionId === selectedConnection.id)) throw conflict("This connection has no permitted tools. Review its action permissions before continuing."); diff --git a/server/src/services/connection-tool-definitions.ts b/server/src/services/connection-tool-definitions.ts index e6cb15f7e6..ff9d702262 100644 --- a/server/src/services/connection-tool-definitions.ts +++ b/server/src/services/connection-tool-definitions.ts @@ -15,7 +15,7 @@ export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [ description: CONNECTION_REQUEST_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { service: { type: "string" }, targetService: { type: "string", description: "App slug returned by search only when the user explicitly named this external provider" }, selectionInteractionId: { type: "string", description: "Saved answered provider-choice interaction ID for aggregator routes" } }, + properties: { service: { type: "string" }, connectionId: { type: "string", description: "Reuse this saved connection" }, toolNames: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20, uniqueItems: true, description: "Exact indexed tools needed by this agent" }, targetService: { type: "string", description: "App slug returned by search only when the user explicitly named this external provider" }, selectionInteractionId: { type: "string", description: "Saved answered provider-choice interaction ID for aggregator routes" } }, required: ["service"], additionalProperties: false, }, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 764d1db904..9db918363c 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -2614,7 +2614,7 @@ export function issueThreadInteractionService( || existing.sourceRunId !== input.sourceRunId || existing.addresseeUserId !== input.addresseeUserId || (existing.kind === "connection_intent" - ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose) + ? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose || !isDeepStrictEqual(connectionIntentPayloadSchema.parse(existing.payload).accessRequest, payload.accessRequest)) : !isDeepStrictEqual(existing.payload, payload)) ) { throw conflict( @@ -2651,7 +2651,7 @@ export function issueThreadInteractionService( eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId), )); const reusable = pending.find((candidate) => - connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose); + connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose && isDeepStrictEqual(connectionIntentPayloadSchema.parse(candidate.payload).accessRequest, payload.accessRequest)); if (reusable) return reusable; const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns) @@ -2674,7 +2674,7 @@ export function issueThreadInteractionService( sourceRunId: input.sourceRunId, originCommentIds, sourceIdentityContextId: input.sourceIdentityContextId ?? null, - title: `Connect ${payload.serviceName}`, + title: payload.accessRequest ? `Grant ${payload.serviceName} access to ${payload.requestingAgentName}?` : `Connect ${payload.serviceName}`, summary: `${payload.requestingAgentName} needs this connection to continue.`, createdByAgentId: payload.requestingAgentId, addresseeUserId: input.addresseeUserId, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index e67e02b54f..50e62e7573 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -304,7 +304,7 @@ export class PaperclipRunnerToolAuthority { return connections.search(claims, input.query, { retryProviderChoice: input.retryProviderChoice }); } const input = connectionRequestInputSchema.parse(call.arguments); - const result = await connections.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService }); + const result = await connections.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService, connectionId: input.connectionId, toolNames: input.toolNames }); if (result.state === "ready" && this.binding.pinnedMcpDigest && this.binding.enqueueWakeup) { const current = await resolveNativeRuntimeMcpSnapshot({ db: this.db, agent: { id: this.binding.agentId, companyId: this.binding.companyId }, runId: this.binding.runId }); if (current.digest !== this.binding.pinnedMcpDigest) { diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index aea8c2cba9..60db597245 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -1453,7 +1453,10 @@ export function toolAccessPolicyService(db: Db) { if (policy.policyType === "require_approval") { // The connection's Ask first control restricts an existing action grant; // it must never grant access to agents outside that connection's profile. - if (policy.config?.source === "app_gallery_finish" && !permittedByProfile) continue; + if (["app_gallery_finish", "connection_intent"].includes(String(policy.config?.source)) && !permittedByProfile) continue; + if (policy.config?.source === "connection_intent" && explicitBlock) { + return decision("deny", "deny_policy_block", explicitBlock.policy.description ?? "Tool access is blocked by policy.", effectiveProfileIds, [explicitBlock.policy.id], { redactionPlan: redaction.redactionPlan }); + } return decision("require_approval", "requires_approval_policy", policy.description ?? "Tool access requires approval.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation }); } if (policy.policyType === "allow") { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 6f9ad22446..dda69da2fc 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,9 @@ +import { AGGREGATOR_NAMES, isAppAggregator, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "@paperclipai/shared/aggregator-apps"; +import { resolveAggregatorApp, type AppCatalogAggregator } from "@paperclipai/shared/aggregator-app-catalog"; +import { AggregatorDiscoveryUnavailableError, discoverArcadeApps, discoverExecutorApps, type DiscoveredApp } from "./aggregator-app-discovery.js"; +import { COMPOSIO_APP_TOOLKITS, findComposioCatalogApp } from "@paperclipai/shared/aggregator-app-catalog"; +import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared"; +import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; @@ -58,6 +64,8 @@ import { toolActionRequests, toolCatalogEntries, toolConnectionInstalls, + toolConnectionAppSnapshots, + toolConnectionAppSyncs, toolConnections, toolOauthStates, toolStdioCommandTemplates, @@ -657,6 +665,8 @@ type ToolAccessServiceOptions = { remoteHttpEndpointLookup?: RemoteHttpEndpointLookup; /** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */ remoteHttpRequest?: (url: string, init: RequestInit) => Promise; + /** Smaller reviewed catalog for deterministic discovery fixtures. */ + composioAppToolkits?: readonly string[]; /** Test seam for the centrally registered Gmail OAuth broker. */ paperclipCloudConnector?: PaperclipCloudConnector | null; /** @deprecated Use paperclipCloudConnector. */ @@ -3033,6 +3043,9 @@ export function toolAccessService( options: ToolAccessServiceOptions = {}, ) { const secrets = secretService(db); + const composioSyncJobs = new Map>(); + const composioSyncTtlMs = 5 * 60_000; + const composioSyncLeaseMs = 4 * 60_000; async function resolvedRemoteEndpoint( connection: typeof toolConnections.$inferSelect, @@ -3629,6 +3642,360 @@ export function toolAccessService( return isToolConnectionAttentionHealth(status); } + async function composioCredentialKey(connection: typeof toolConnections.$inferSelect, actor: ActorInfo) { + const usesConnectionRefs = ["arcade", "executor"].includes(String(connection.config.sourceTemplateKey)) && connection.authKind !== "oauth" && connection.credentialPolicy === "shared"; + const grant = usesConnectionRefs ? null : await vaultGrantForConnection(connection, actor); + const refs = grant?.credentialSecretRefs ?? connection.credentialSecretRefs; + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + const ids = [...new Set([...refs.map(ref => ref.secretId), ...(typeof discovery.secretId === "string" ? [discovery.secretId] : [])])].sort(); + const versions = ids.length ? await db.select({ id: companySecrets.id, version: companySecrets.latestVersion }).from(companySecrets) + .where(and(eq(companySecrets.companyId, connection.companyId), inArray(companySecrets.id, ids))).orderBy(asc(companySecrets.id)) : []; + return createHash("sha256").update(JSON.stringify({ grant: grant?.id, refs, versions, + ...(Object.keys(discovery).length ? { discovery } : {}), + ...(connection.config.sourceTemplateKey === "arcade" || connection.config.sourceTemplateKey === "executor" + ? { headers: projectedConnectionHeaders(connection), managementUrl: connection.config.managementUrl } : {}), endpoint: remoteEndpoint(connection.config), policy: connection.credentialPolicy, connectedAt: asRecord(connection.config.oauth)?.connectedAt })).digest("hex"); + } + + async function cachedComposioApps(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise { + const credentialKey = await composioCredentialKey(connection, actor); + const rows = await db.select().from(toolConnectionAppSnapshots).where(and( + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connection.id), + eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + )); + return rows.map(row => ({ connectionId: row.connectionId, toolkit: row.toolkit, status: row.status, accounts: row.accounts, + checkedAt: row.checkedAt.toISOString(), errorAt: row.errorAt?.toISOString() ?? null })); + } + + async function composioAppsResponse(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise { + const credentialKey = await composioCredentialKey(connection, actor); + const [sync] = await db.select().from(toolConnectionAppSyncs).where(and( + eq(toolConnectionAppSyncs.companyId, connection.companyId), eq(toolConnectionAppSyncs.connectionId, connection.id), + eq(toolConnectionAppSyncs.userId, actor.actorId!), eq(toolConnectionAppSyncs.credentialKey, credentialKey), + )); + const interrupted = sync?.status === "syncing" && sync.updatedAt.getTime() < Date.now() - composioSyncLeaseMs; + return { apps: await cachedComposioApps(connection, actor), sync: { + status: interrupted || sync?.status === "unsupported" ? "error" : sync?.status ?? "idle", coverage: "supported_catalog", + checked: sync?.checked ?? 0, total: sync?.total ?? 0, failed: sync?.failed ?? 0, + lastCompletedAt: sync?.lastCompletedAt?.toISOString() ?? null, + error: interrupted ? "App checks were interrupted. Refresh to try again." + : (sync?.status === "error" || sync?.status === "unsupported") ? "Couldn’t finish checking Composio apps. Refresh to try again." : null, + } }; + } + + async function openComposioGateway(connectionId: string, actor: ActorInfo) { + const connection = await getConnectionRow(connectionId); + if (actor.actorType !== "user" || !actor.actorId) throw forbidden("A human connection manager must configure this app"); + if (connection.config.sourceTemplateKey !== "composio" || connection.transport !== "mcp_remote" + || connection.status !== "active" || !connection.enabled) throw unprocessable("Connect your Composio gateway first"); + const catalog = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), + )); + if (!catalog.some(tool => tool.toolName === "COMPOSIO_MANAGE_CONNECTIONS" && tool.status === "active")) { + throw unprocessable("Refresh your Composio gateway's actions before configuring this app"); + } + const endpoint = await resolvedRemoteEndpoint(connection, actor); + const headers = { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(connection, actor)) }; + const credentialKey = await composioCredentialKey(await getConnectionRow(connectionId), actor); + const send = (init: RequestInit) => requestRemoteHttpEndpoint(new URL(endpoint), { ...init, signal: AbortSignal.timeout(30_000) }); + let sessionHeaders = connection.config.mcpSessionRequired === true + ? await getMcpHttpSession({ send, headers, scope: `${connection.id}:app-setup:${actor.actorId}`, requestId: "paperclip-app-setup" }) : headers; + async function manage(toolkits: { name: string; action: "list" | "add" | "rename" | "remove"; account_id?: string; alias?: string }[], tolerateToolkitErrors = false) { + const checkedAt = new Date(); + const id = `paperclip-app-setup-${randomUUID()}`; + const request = () => send({ method: "POST", headers: mcpHttpRequestHeaders(sessionHeaders), body: JSON.stringify({ + jsonrpc: "2.0", id, method: "tools/call", params: { name: "COMPOSIO_MANAGE_CONNECTIONS", arguments: { toolkits } }, + }) }); + const markFailed = async () => { + const names = toolkits.filter(tool => tool.action === "list").map(tool => tool.name); + if (names.length) await db.update(toolConnectionAppSnapshots).set({ errorAt: checkedAt }).where(and( + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), + eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + inArray(toolConnectionAppSnapshots.toolkit, names), lte(toolConnectionAppSnapshots.checkedAt, checkedAt), + )); + }; + try { + let response = await request(); + // Only reads retry. An uncertain account mutation must never repeat automatically. + if (toolkits.every(tool => tool.action === "list") && (response.status === 400 || response.status === 404)) { + await response.body?.cancel(); + sessionHeaders = await initializeMcpHttpSession({ send, headers, requestId: id }); + response = await request(); + } + if (!response.ok) throw new HttpError(502, toolkits.every(tool => tool.action === "list") + ? "Composio could not check this app. Reconnect the gateway if its sign-in expired." + : "Composio has not confirmed this change. Refresh the accounts before trying again.", { code: "composio_app_setup_failed", status: response.status }); + const payload = await readMcpHttpResponse(response, id); + if (await composioCredentialKey(await getConnectionRow(connectionId), actor) !== credentialKey) { + throw conflict("The Composio account changed while checking apps. Refresh to check the current account."); + } + const failedToolkits: string[] = []; + for (const tool of toolkits.filter(tool => tool.action === "list")) { + let accounts: ComposioAppAccount[]; + try { + composioAppSetupResult(payload, tool.name); + const parsed = composioAppAccounts(payload, tool.name); + if (!parsed) throw unprocessable("Composio did not return a complete account list. Refresh the accounts before trying again."); + accounts = parsed; + } catch (error) { + await db.update(toolConnectionAppSnapshots).set({ errorAt: checkedAt }).where(and( + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), + eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + eq(toolConnectionAppSnapshots.toolkit, tool.name), lte(toolConnectionAppSnapshots.checkedAt, checkedAt), + )); + if (!tolerateToolkitErrors) throw error; + failedToolkits.push(tool.name); + continue; + } + const status = accounts.some(account => account.status === "ACTIVE") ? "connected" as const : "not_connected" as const; + await db.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, + credentialKey, toolkit: tool.name, accounts, status, checkedAt, errorAt: null, + }).onConflictDoUpdate({ target: [toolConnectionAppSnapshots.companyId, toolConnectionAppSnapshots.connectionId, toolConnectionAppSnapshots.userId, toolConnectionAppSnapshots.toolkit], + set: { accounts, status, checkedAt, credentialKey, errorAt: null }, setWhere: lte(toolConnectionAppSnapshots.checkedAt, checkedAt) }); + } + return { payload, failedToolkits }; + } catch (error) { await markFailed(); throw error; } + } + return { connection, catalog, credentialKey, manage: async (...args: Parameters) => (await manage(...args)).payload, checkBatch: manage }; + } + + async function startComposioAppSync(connectionId: string, force: boolean, actor: ActorInfo) { + const opened = await openComposioGateway(connectionId, actor); + const { connection, credentialKey } = opened; + const existing = await cachedComposioApps(connection, actor); + const catalog = options.composioAppToolkits ?? COMPOSIO_APP_TOOLKITS; + const toolkits = [...new Set([...existing.filter(app => app.accounts.length > 0).map(app => app.toolkit), ...catalog])]; + const leaseId = randomUUID(); + const startedAt = new Date(); + const [lease] = await db.insert(toolConnectionAppSyncs).values({ companyId: connection.companyId, connectionId, + userId: actor.actorId!, credentialKey, leaseId, status: "syncing", total: toolkits.length, updatedAt: startedAt, + }).onConflictDoUpdate({ target: [toolConnectionAppSyncs.companyId, toolConnectionAppSyncs.connectionId, toolConnectionAppSyncs.userId, toolConnectionAppSyncs.credentialKey], + set: { leaseId, status: "syncing", checked: 0, total: toolkits.length, failed: 0, updatedAt: startedAt }, + setWhere: and( + or(ne(toolConnectionAppSyncs.status, "syncing"), lt(toolConnectionAppSyncs.updatedAt, new Date(Date.now() - composioSyncLeaseMs))), + force ? sql`true` : or(ne(toolConnectionAppSyncs.status, "ready"), isNull(toolConnectionAppSyncs.lastCompletedAt), + lt(toolConnectionAppSyncs.lastCompletedAt, new Date(Date.now() - composioSyncTtlMs))), + ), + }).returning(); + if (lease) { + const updateLease = async (values: Partial) => { + const updated = await db.update(toolConnectionAppSyncs).set({ ...values, updatedAt: new Date() }) + .where(and(eq(toolConnectionAppSyncs.id, lease.id), eq(toolConnectionAppSyncs.leaseId, leaseId))).returning({ id: toolConnectionAppSyncs.id }); + if (!updated.length) throw conflict("App checks were replaced by a newer sync."); + }; + const job = (async () => { + let failed = 0; + try { + for (let start = 0; start < toolkits.length; start += 32) { + if (Date.now() - startedAt.getTime() > composioSyncLeaseMs) throw new Error("Sync deadline exceeded"); + await updateLease({}); + const current = start === 0 ? opened : await openComposioGateway(connectionId, actor); + if (current.credentialKey !== credentialKey) throw conflict("Composio credentials changed during sync."); + const batch = toolkits.slice(start, start + 32); + const result = await current.checkBatch(batch.map(name => ({ name, action: "list" })), true); + failed += result.failedToolkits.length; + await updateLease({ checked: start + batch.length, failed }); + } + await updateLease({ status: failed > 0 ? "error" : "ready", lastCompletedAt: new Date(), failed }); + } catch { + const [activeLease] = await db.select({ id: toolConnectionAppSyncs.id }).from(toolConnectionAppSyncs) + .where(and(eq(toolConnectionAppSyncs.id, lease.id), eq(toolConnectionAppSyncs.leaseId, leaseId))); + if (!activeLease) return; + // Preserve observations, but never present a failed check as current authorization. + await db.update(toolConnectionAppSnapshots).set({ errorAt: new Date() }).where(and( + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), + eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + lte(toolConnectionAppSnapshots.checkedAt, startedAt), + )); + await updateLease({ status: "error", failed }).catch(() => undefined); + } + })(); + composioSyncJobs.set(leaseId, job); + void job.finally(() => composioSyncJobs.delete(leaseId)).catch(() => undefined); + } + return composioAppsResponse(await getConnectionRow(connectionId), actor); + } + + function aggregatorProvider(connection: typeof toolConnections.$inferSelect): AppCatalogAggregator { + const provider = connection.config.sourceTemplateKey; + if (!isAppAggregator(provider) || connection.transport !== "mcp_remote") throw notFound("Aggregator gateway not found"); + return provider; + } + + function assertDiscoveryActor(actor: ActorInfo) { + if (actor.actorType !== "user" || !actor.actorId) throw forbidden("A human connection manager must check accounts"); + } + + async function aggregatorDiscoveryState(connection: typeof toolConnections.$inferSelect, actor: ActorInfo, retryUnsupported = false): Promise { + const provider = aggregatorProvider(connection); + if (connection.status !== "active" || !connection.enabled) return { availability: "disabled", message: "Restore this gateway to check its accounts." }; + if (provider === "arcade") { + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + const headers = projectedConnectionHeaders(connection); + const grant = connection.authKind === "oauth" || connection.credentialPolicy !== "shared" ? await vaultGrantForConnection(connection, actor) : null; + const refs = grant?.credentialSecretRefs ?? connection.credentialSecretRefs; + const hasUserId = Boolean(Object.entries(headers).find(([key]) => key.toLowerCase() === "arcade-user-id")?.[1]) + || refs.some(ref => ref.configPath.toLowerCase() === "headers.arcade-user-id"); + if ((!discovery.secretId || !discovery.userId) && !(hasUserId && connection.authKind === "api_key")) { + return { availability: "setup_required", message: "Set up account sync to see your Arcade apps here." }; + } + } + const catalog = await db.select({ name: toolCatalogEntries.toolName }).from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), eq(toolCatalogEntries.status, "active"), + )); + if ((provider === "executor" && !catalog.some(tool => ["integrations", "execute"].includes(tool.name))) + || (provider === "composio" && !catalog.some(tool => tool.name === "COMPOSIO_MANAGE_CONNECTIONS"))) { + return { availability: "unsupported", message: `${AGGREGATOR_NAMES[provider]} account discovery is unavailable on this gateway. Refresh its actions or check its setup.` }; + } + if (!retryUnsupported && provider === "executor") { + const credentialKey = await composioCredentialKey(connection, actor); + const [sync] = await db.select({ status: toolConnectionAppSyncs.status }).from(toolConnectionAppSyncs).where(and( + eq(toolConnectionAppSyncs.companyId, connection.companyId), eq(toolConnectionAppSyncs.connectionId, connection.id), + eq(toolConnectionAppSyncs.userId, actor.actorId!), eq(toolConnectionAppSyncs.credentialKey, credentialKey), + )); + if (sync?.status === "unsupported") return { availability: "unsupported", message: "Executor account discovery is unavailable on this server. You can still use the gateway’s actions." }; + } + return { availability: "available", message: null }; + } + + async function aggregatorAppsResponse(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise { + assertDiscoveryActor(actor); + const provider = aggregatorProvider(connection); + const result = await composioAppsResponse(connection, actor); + const discovery = await aggregatorDiscoveryState(connection, actor); + return { provider, discovery, apps: result.apps.map(snapshot => { + const app = resolveAggregatorApp(provider, snapshot.toolkit, snapshot.accounts[0]?.appName); + return { ...snapshot, provider, freshness: discovery.availability !== "available" || snapshot.errorAt || result.sync.status === "error" || connection.status !== "active" || !connection.enabled || Date.now() - new Date(snapshot.checkedAt).getTime() > composioSyncTtlMs ? "stale" as const : "fresh" as const, appSlug: app.slug.startsWith(`${provider}:`) ? `${provider}:${connection.id}:${snapshot.toolkit}` : snapshot.accounts[0]?.appSlug ?? app.slug, appName: snapshot.accounts[0]?.appName ?? app.name }; + }), sync: { ...result.sync, coverage: provider === "composio" ? "supported_catalog" : provider === "arcade" ? "gateway_tools" : "visible_accounts", + error: result.sync.error ? `Couldn’t finish checking ${AGGREGATOR_NAMES[provider]} apps. Refresh to try again.` : null, + } }; + } + + async function discoverAggregatorInventory(connection: typeof toolConnections.$inferSelect, actor: ActorInfo): Promise { + const provider = aggregatorProvider(connection); + const tools = await remoteTools(connection, undefined, actor); + connection = await getConnectionRow(connection.id); + const headers = { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(connection, actor)) }; + const endpoint = new URL(await resolvedRemoteEndpoint(connection, actor)); + if (provider === "arcade") { + // The extra project credential never joins invocation headers or tool grants. + if (endpoint.hostname !== "api.arcade.dev" || endpoint.protocol !== "https:") throw unprocessable("Account sync requires an Arcade Cloud gateway"); + const discovery = asRecord(asRecord(connection.config.aggregatorDiscovery)[actor.actorId!]); + let key: string | undefined; + let userId: string | undefined; + if (typeof discovery.secretId === "string" && typeof discovery.userId === "string") { + const [secret] = await db.select().from(companySecrets).where(and(eq(companySecrets.id, discovery.secretId), eq(companySecrets.companyId, connection.companyId), eq(companySecrets.ownerUserId, actor.actorId!))); + if (!secret?.userSecretDefinitionId) throw forbidden("Your Arcade discovery credential is unavailable"); + const resolved = await secrets.resolveUserSecretValue(connection.companyId, { definitionId: secret.userSecretDefinitionId, responsibleUserId: actor.actorId!, required: true }, { + actorType: "user", actorId: actor.actorId!, consumerType: "tool_connection", consumerId: connection.id, responsibleUserId: actor.actorId!, + }); + key = resolved?.value; + userId = discovery.userId; + } else { + userId = Object.entries(headers).find(([name]) => name.toLowerCase() === "arcade-user-id")?.[1]; + if (connection.authKind === "api_key") key = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization")?.[1].replace(/^Bearer\s+/i, ""); + } + if (!key || !userId) throw unprocessable("Set up Arcade account sync first"); + return discoverArcadeApps({ userId, gatewayTools: tools.map(tool => tool.name), request: async path => { + const response = await requestRemoteHttpEndpoint(new URL(path, endpoint.origin), { method: "GET", headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(30_000) }); + if (!response.ok) { await response.body?.cancel(); throw new HttpError(502, "Arcade could not list your accounts. Check the project API key and Arcade user ID."); } + return response.json(); + } }); + } + const send = (init: RequestInit) => requestRemoteHttpEndpoint(endpoint, { ...init, signal: AbortSignal.timeout(30_000) }); + let sessionHeaders = connection.config.mcpSessionRequired === true ? await getMcpHttpSession({ send, headers, scope: `${connection.id}:inventory:${actor.actorId}`, requestId: "paperclip-account-inventory" }) : headers; + return discoverExecutorApps({ toolNames: tools.map(tool => tool.name), managementUrl: typeof connection.config.managementUrl === "string" ? connection.config.managementUrl : null, + call: async (name, args) => { + const id = `paperclip-account-inventory-${randomUUID()}`; + const request = () => send({ method: "POST", headers: mcpHttpRequestHeaders(sessionHeaders), body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }) }); + let response = await request(); + if (response.status === 400 || response.status === 404) { + await response.body?.cancel(); sessionHeaders = await initializeMcpHttpSession({ send, headers, requestId: id }); response = await request(); + } + if (!response.ok) { await response.body?.cancel(); throw new HttpError(502, "Executor could not list your accounts. Reconnect if sign-in expired."); } + return readMcpHttpResponse(response, id); + }, + }); + } + + async function syncAggregatorApps(connectionId: string, force: boolean, actor: ActorInfo): Promise { + assertDiscoveryActor(actor); + let connection = await getConnectionRow(connectionId); + const provider = aggregatorProvider(connection); + const discovery = await aggregatorDiscoveryState(connection, actor, force); + if (discovery.availability !== "available") return aggregatorAppsResponse(connection, actor); + if (provider === "composio") { + await startComposioAppSync(connectionId, force, actor); + return aggregatorAppsResponse(await getConnectionRow(connectionId), actor); + } + // Resolve/refresh the caller's gateway credentials before fingerprinting the lease. + await resolveCredentialHeaders(connection, actor); + connection = await getConnectionRow(connectionId); + const credentialKey = await composioCredentialKey(connection, actor); + const leaseId = randomUUID(); + const startedAt = new Date(); + const [lease] = await db.insert(toolConnectionAppSyncs).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, credentialKey, leaseId, status: "syncing", updatedAt: startedAt }) + .onConflictDoUpdate({ target: [toolConnectionAppSyncs.companyId, toolConnectionAppSyncs.connectionId, toolConnectionAppSyncs.userId, toolConnectionAppSyncs.credentialKey], + set: { leaseId, status: "syncing", checked: 0, total: 0, failed: 0, updatedAt: startedAt }, + setWhere: and(or(ne(toolConnectionAppSyncs.status, "syncing"), lt(toolConnectionAppSyncs.updatedAt, new Date(Date.now() - composioSyncLeaseMs))), + force ? sql`true` : or(ne(toolConnectionAppSyncs.status, "ready"), isNull(toolConnectionAppSyncs.lastCompletedAt), lt(toolConnectionAppSyncs.lastCompletedAt, new Date(Date.now() - composioSyncTtlMs)))), + }).returning(); + if (lease) { + const job = (async () => { + try { + const apps = await discoverAggregatorInventory(connection, actor); + await db.transaction(async tx => { + const [current] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, connection.companyId))).for("update"); + const [active] = await tx.select().from(toolConnectionAppSyncs).where(and(eq(toolConnectionAppSyncs.id, lease.id), eq(toolConnectionAppSyncs.leaseId, leaseId))).for("update"); + if (!current || !active || current.status !== "active" || !current.enabled || Date.now() - startedAt.getTime() > composioSyncLeaseMs + || await composioCredentialKey(current, actor) !== credentialKey) throw conflict("The gateway changed during account discovery"); + const checkedAt = new Date(); + // Commit the entire enumeration at once; a failed later page cannot delete accounts. + await tx.update(toolConnectionAppSnapshots).set({ accounts: [], status: "not_connected", checkedAt, errorAt: null }).where(and( + eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey), + )); + for (const app of apps) await tx.insert(toolConnectionAppSnapshots).values({ companyId: connection.companyId, connectionId, userId: actor.actorId!, credentialKey, + toolkit: app.toolkit, accounts: app.accounts, status: app.accounts.some(account => account.status === "ACTIVE") ? "connected" : "not_connected", checkedAt, + }).onConflictDoUpdate({ target: [toolConnectionAppSnapshots.companyId, toolConnectionAppSnapshots.connectionId, toolConnectionAppSnapshots.userId, toolConnectionAppSnapshots.toolkit], + set: { accounts: app.accounts, status: app.accounts.some(account => account.status === "ACTIVE") ? "connected" : "not_connected", checkedAt, errorAt: null, credentialKey }, + }); + await tx.update(toolConnectionAppSyncs).set({ status: "ready", checked: apps.length, total: apps.length, failed: 0, updatedAt: checkedAt, lastCompletedAt: checkedAt }).where(eq(toolConnectionAppSyncs.id, lease.id)); + }); + } catch (error) { + await db.transaction(async tx => { + const [active] = await tx.select().from(toolConnectionAppSyncs).where(and(eq(toolConnectionAppSyncs.id, lease.id), eq(toolConnectionAppSyncs.leaseId, leaseId))).for("update"); + if (!active) return; + await tx.update(toolConnectionAppSnapshots).set({ errorAt: new Date() }).where(and(eq(toolConnectionAppSnapshots.companyId, connection.companyId), eq(toolConnectionAppSnapshots.connectionId, connectionId), eq(toolConnectionAppSnapshots.userId, actor.actorId!), eq(toolConnectionAppSnapshots.credentialKey, credentialKey))); + await tx.update(toolConnectionAppSyncs).set({ status: error instanceof AggregatorDiscoveryUnavailableError ? "unsupported" : "error", failed: 1, updatedAt: new Date() }).where(eq(toolConnectionAppSyncs.id, lease.id)); + }); + } + })(); + composioSyncJobs.set(leaseId, job); + void job.finally(() => composioSyncJobs.delete(leaseId)).catch(() => undefined); + } + return aggregatorAppsResponse(await getConnectionRow(connectionId), actor); + } + + async function configureArcadeDiscovery(connectionId: string, input: ArcadeDiscoverySetupInput, actor: ActorInfo) { + assertDiscoveryActor(actor); + const companyId = (await getConnectionRow(connectionId)).companyId; + await db.transaction(async tx => { + const [connection] = await tx.select().from(toolConnections).where(eq(toolConnections.id, connectionId)).for("update"); + if (!connection || aggregatorProvider(connection) !== "arcade" || connection.status === "archived") throw notFound("Arcade gateway not found"); + const all = asRecord(connection.config.aggregatorDiscovery); + const before = asRecord(all[actor.actorId!]); + const { secret } = await writeConnectionCredential(tx, { + companyId: connection.companyId, connectionName: connection.name, configPath: "aggregatorDiscovery.apiKey", label: "Arcade account sync API key", + value: input.apiKey, ownerUserId: actor.actorId!, actor: actorForSecret(actor), + existingRef: typeof before.secretId === "string" ? { secretId: before.secretId, configPath: "aggregatorDiscovery.apiKey", versionSelector: "latest" } : undefined, + definitionKey: `arcade_discovery.${connection.id}`, + }); + const config = { ...connection.config, aggregatorDiscovery: { ...all, [actor.actorId!]: { userId: input.userId, secretId: secret.id } } }; + await tx.update(toolConnections).set({ config, transportConfig: config, updatedAt: new Date() }).where(eq(toolConnections.id, connectionId)); + }); + await logActivity(db, { companyId, actorType: "user", actorId: actor.actorId!, action: "tool_connection.account_sync_configured", entityType: "tool_connection", entityId: connectionId, details: { provider: "arcade" } }); + return syncAggregatorApps(connectionId, true, actor); + } + async function audit(input: { companyId: string; connectionId?: string | null; @@ -6352,6 +6719,7 @@ export function toolAccessService( // provider error leaves an unresolvable secret and a resumable removal // rather than a half-open app. const candidateSecretIds = [ + ...Object.values(asRecord(connection.config.aggregatorDiscovery)).flatMap(value => typeof asRecord(value).secretId === "string" ? [asRecord(value).secretId as string] : []), ...connection.credentialRefs.map((ref) => ref.secretId), ...connection.credentialSecretRefs.map((ref) => ref.secretId), ...grantRows.flatMap((grant) => @@ -16698,6 +17066,100 @@ export function toolAccessService( return toStdioCommandTemplate(row); }, + async setupComposioApp(connectionId: string, toolkit: string, input: ComposioAppSetupInput, actor: ActorInfo): Promise { + if (!findComposioCatalogApp(toolkit)) throw notFound("This Composio app is not in the catalog"); + const { connection, manage } = await openComposioGateway(connectionId, actor); + let result = composioAppSetupResult(await manage([{ name: toolkit, action: "list" }]), toolkit); + if (input.action === "start" && result.status !== "connected") { + result = composioAppSetupResult(await manage([{ name: toolkit, action: "add" }]), toolkit); + if (result.status === "not_connected") throw unprocessable("Composio did not return a sign-in link for this app. Manage it in Composio to check its setup requirements."); + } + if (input.action === "complete") { + if (result.status !== "connected") throw conflict("Finish connecting this app in Composio, then check again"); + const currentConnection = await getConnectionRow(connectionId); + if (currentConnection.status !== "active" || !currentConnection.enabled) { + throw conflict("The gateway is no longer active. Restore it before connecting this app."); + } + } + await audit({ companyId: connection.companyId, connectionId, action: "tool_connection.upstream_app_setup", outcome: "success", actor, + details: { toolkit, action: input.action, status: result.status } }); + return result; + }, + + listAggregatorApps: async (connectionId: string, actor: ActorInfo) => aggregatorAppsResponse(await getConnectionRow(connectionId), actor), + syncAggregatorApps, + refreshAggregatorApps: async (connectionId: string, requested: string[], actor: ActorInfo) => { + const connection = await getConnectionRow(connectionId); + if (aggregatorProvider(connection) === "composio" && requested.length) { + const { manage } = await openComposioGateway(connectionId, actor); + if (requested.some(toolkit => !findComposioCatalogApp(toolkit))) throw notFound("This Composio app is not in the catalog"); + await manage(requested.map(name => ({ name, action: "list" }))); + return aggregatorAppsResponse(connection, actor); + } + return syncAggregatorApps(connectionId, true, actor); + }, + configureArcadeDiscovery, + syncComposioApps: startComposioAppSync, + + async listComposioApps(connectionId: string, actor: ActorInfo) { + const connection = await getConnectionRow(connectionId); + if (actor.actorType !== "user" || !actor.actorId) throw forbidden("Board access required"); + if (connection.config.sourceTemplateKey !== "composio") throw notFound("Composio gateway not found"); + return composioAppsResponse(connection, actor); + }, + + async refreshComposioApps(connectionId: string, requested: string[], actor: ActorInfo) { + if (requested.some(toolkit => !findComposioCatalogApp(toolkit))) throw notFound("This Composio app is not in the catalog"); + const { connection, manage } = await openComposioGateway(connectionId, actor); + const existing = await cachedComposioApps(connection, actor); + // Migrate observations from the original direct setup flow on the first visit. + const history = await db.select({ details: toolAccessAuditEvents.details }).from(toolAccessAuditEvents).where(and( + eq(toolAccessAuditEvents.companyId, connection.companyId), eq(toolAccessAuditEvents.connectionId, connectionId), + eq(toolAccessAuditEvents.actorId, actor.actorId!), eq(toolAccessAuditEvents.action, "tool_connection.upstream_app_setup"), + )).orderBy(desc(toolAccessAuditEvents.createdAt)).limit(128); + const known = [...existing.filter(app => app.accounts.length > 0).map(app => app.toolkit), + ...history.map(row => row.details.toolkit).filter((toolkit): toolkit is string => typeof toolkit === "string" && Boolean(findComposioCatalogApp(toolkit)))]; + const toolkits = [...new Set([...requested, ...known])]; + for (let start = 0; start < toolkits.length; start += 32) { + await manage(toolkits.slice(start, start + 32).map(name => ({ name, action: "list" }))); + } + return composioAppsResponse(connection, actor); + }, + + async manageComposioAppAccount(connectionId: string, toolkit: string, input: ComposioAppAccountInput, actor: ActorInfo) { + if (!findComposioCatalogApp(toolkit)) throw notFound("This Composio app is not in the catalog"); + const { connection, manage } = await openComposioGateway(connectionId, actor); + if (input.action !== "add") { + const before = composioAppAccounts(await manage([{ name: toolkit, action: "list" }]), toolkit); + if (!before?.some(account => account.id === input.accountId)) throw notFound("This account is no longer available in the selected Composio app"); + } + const details = { toolkit, ...(input.action !== "add" ? { accountId: input.accountId } : {}) }; + await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: actor.actorId!, + action: `tool_connection.upstream_account_${input.action}_requested`, entityType: "tool_connection", entityId: connectionId, details }); + let payload: unknown; + try { + payload = await manage([{ name: toolkit, action: input.action, + ...(input.action !== "add" ? { account_id: input.accountId } : {}), ...(input.action === "rename" ? { alias: input.alias } : {}) }]); + } catch (error) { + await audit({ companyId: connection.companyId, connectionId, actor, action: `tool_connection.upstream_account_${input.action}`, + outcome: "failure", reasonCode: "provider_unconfirmed", details }); + throw error; + } + const result = composioAppSetupResult(payload, toolkit); // Reject provider errors before reporting success. + if (input.action === "add" && !result.authorizationUrl) throw unprocessable("Composio did not return a sign-in link. Check the app in Composio."); + if (input.action !== "add") { + const after = composioAppAccounts(await manage([{ name: toolkit, action: "list" }]), toolkit); + if (!after || (input.action === "remove" ? after.some(account => account.id === input.accountId) + : !after.some(account => account.id === input.accountId && account.alias === input.alias))) { + throw conflict("Composio has not confirmed this change. Refresh the accounts before trying again."); + } + } + await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: actor.actorId!, + action: `tool_connection.upstream_account_${input.action}`, entityType: "tool_connection", entityId: connectionId, + details: { toolkit, ...(input.action !== "add" ? { accountId: input.accountId } : {}) } }); + return { ...result, apps: await cachedComposioApps(connection, actor) }; + }, + connectGalleryApp, finishGalleryAppConnection, diff --git a/server/src/services/tool-profile-binding-precedence.test.ts b/server/src/services/tool-profile-binding-precedence.test.ts index f7bc39fdc8..84aa686ca7 100644 --- a/server/src/services/tool-profile-binding-precedence.test.ts +++ b/server/src/services/tool-profile-binding-precedence.test.ts @@ -7,6 +7,16 @@ import { const createdAt = new Date("2026-08-11T00:00:00.000Z"); describe("tool profile binding precedence", () => { + it("adds an agent connection grant without dropping broader access to other apps", () => { + const company = { profileId: "company", targetType: "company" as const, targetId: "company-1", priority: 100, createdAt }; + const grant = { profileId: "grant", targetType: "agent" as const, targetId: "agent-1", priority: 100, createdAt }; + const profiles = [ + { id: "company", profileKey: "default", metadata: {} }, + { id: "grant", profileKey: "connection-intent:connection-1:agent-1", metadata: { source: "connection_intent", connectionId: "connection-1", agentId: "agent-1" } }, + ]; + expect(effectiveToolProfileBindings([company, grant], profiles, "connection-1")).toEqual([company, grant]); + expect(effectiveToolProfileBindings([company, grant], profiles, "connection-2")).toEqual([company]); + }); it("keeps ordinary profiles at the narrowest matching scope", () => { const companyBinding = { profileId: "company-profile", diff --git a/server/src/services/tool-profile-binding-precedence.ts b/server/src/services/tool-profile-binding-precedence.ts index 4ed8503856..73b94c7131 100644 --- a/server/src/services/tool-profile-binding-precedence.ts +++ b/server/src/services/tool-profile-binding-precedence.ts @@ -59,6 +59,10 @@ export function profileIdsInBindingOrder; + if (metadata.source === "connection_intent" && typeof metadata.connectionId === "string" && typeof metadata.agentId === "string") { + return profile.profileKey === `connection-intent:${metadata.connectionId}:${metadata.agentId}` + && (connectionId == null || metadata.connectionId === connectionId); + } if (metadata.source !== "app_gallery_finish" || typeof metadata.connectionId !== "string") return false; if (profile.profileKey !== `app:${metadata.connectionId}`) return false; return connectionId === undefined || connectionId === null || metadata.connectionId === connectionId; @@ -83,8 +87,12 @@ export function effectiveToolProfileBindings( const appProfileIds = new Set( profiles.filter((profile) => isWizardAppProfile(profile, connectionId)).map((profile) => profile.id), ); + const accessProfileIds = new Set(profiles.filter(profile => { + const metadata = profile.metadata as Record | null; + return metadata?.source === "connection_intent"; + }).map(profile => profile.id)); const selected = [ - ...narrowestScopeBindings(bindings), + ...narrowestScopeBindings(bindings.filter(binding => !accessProfileIds.has(binding.profileId))), ...bindings.filter((binding) => appProfileIds.has(binding.profileId)), ]; const seen = new Set(); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 69a1398837..3d78273f52 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -727,3 +727,14 @@ For detailed API tables, JSON response schemas, worked examples (IC and Manager When the user answers a pending confirmation in a message, record the answer before acting. Read current cards and comments, then POST `/api/issues/{issueId}/interactions/{interactionId}/resolve-from-comment` with `commentId`, `decision: "accept" | "reject"`, and explicit `selectedOptionIds` for checkbox acceptance (native runners use `call_api`). Ambiguous replies among proposals require clarification. Revisions are not acceptance. Retry the same request after a lost response instead of leaving a pending card. Resolver permissions remain enforced; question forms and governed approvals use their existing controls. See the API reference for scope and retry rules. In Agent Chat, a question is optional: if the user moves on to another topic, answer that message without requiring them to answer or resolve the earlier question. Leave its card unanswered so they can reopen it later. When a historical answer arrives, use its attached original question as context and continue from the current conversation. Unrelated messages are never approval. + +**Connection access requests.** + +Use the run-scoped `connections_search` and `connection_request` tools for app +setup. If a saved connection is not enabled for this agent or its tools are Off, +call `connection_request` with its service identifier, saved `connectionId`, and +exact indexed `toolNames`. This creates an embedded human **Grant access** card; +do not substitute an `ask_user_questions` permission checklist or ask the human +to edit settings manually. Yield while waiting. Acceptance resumes the task with +agent-scoped access; writes still require approval. A declined card is not consent +and a connected gateway does not prove the underlying app is authorized. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 867c713ecf..8e95d9fef3 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -1673,3 +1673,21 @@ Every successful or failed value fetch writes both `secret_access_events` and `a | Sit silently on blocked work | Nobody knows you're stuck; the task rots | Record the blocker and use a saved interaction or dependency | | Leave tasks in ambiguous states | Others can't tell if work is progressing | Always update status: `blocked`, `in_review`, or `done` | | Block on another task without `blockedByIssueIds` | No automatic wake when blocker resolves; manual follow-up needed | Set `blockedByIssueIds` so Paperclip auto-wakes the assignee when all blockers are done | + +**Run-scoped agent connection access.** + +`POST /api/runtime-tools/connections/request` uses the injected runtime tool +capability, not a board session or ordinary agent key. Input: +`{service, connectionId?, toolNames?, selectionInteractionId?, targetService?}`. +`toolNames` contains 1–20 unique indexed names; company, agent, user, and task +identity come from the active run. Missing access to an eligible saved connection +creates a server-owned `connection_intent` with `payload.accessRequest` containing +the connection ID/name and immutable catalog IDs, names, version hashes, and +Allowed/Ask-first settings. The addressed human connection manager accepts via +`POST /api/connection-intents/:id/complete` with `{connectionId}` or declines via +`POST /api/connection-intents/:id/decline`. Acceptance atomically installs the +connection for the requesting agent, grants only the listed tools, retains +per-call write approval, records activity, and dispatches the existing continuation. +An unauthorized approver receives 403; changed tool definitions, assignment, +identity, or a closed task receive 409; unrelated connections receive 404. +No credentials or provider authorization URL appear in the card payload. diff --git a/tests/aggregator-accounts/test-drive.ts b/tests/aggregator-accounts/test-drive.ts new file mode 100644 index 0000000000..fe4e3f907a --- /dev/null +++ b/tests/aggregator-accounts/test-drive.ts @@ -0,0 +1,81 @@ +/** Disposable acceptance server: production routes/database, deterministic upstream providers. */ +import express from "../../server/node_modules/express/index.js"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { createDb, startEmbeddedPostgresTestDatabase, companies, companyMemberships, toolApplications, toolConnections, connectionGrants, toolCatalogEntries } from "../../packages/db/src/index.ts"; +import { toolAccessRoutes } from "../../server/src/routes/tool-access.js"; +import { toolAccessService } from "../../server/src/services/tool-access.js"; +import { errorHandler } from "../../server/src/middleware/error-handler.js"; + +// Keep startup alive while the test helper probes ports with unreferenced sockets. +const startup = setInterval(() => undefined, 1000); +async function startAcceptanceDatabase() { + let failure: unknown; + for (let attempt = 0; attempt < 30; attempt++) { + try { return await startEmbeddedPostgresTestDatabase("paperclip-aggregator-browser-"); } + catch (error) { failure = error; await new Promise(resolve => setTimeout(resolve, 2000)); } + } + throw failure; +} +const database = await startAcceptanceDatabase(); +fs.writeFileSync("/tmp/paperclip-aggregator-acceptance-db.json", JSON.stringify({ connectionString: database.connectionString }), { mode: 0o600 }); +const db = createDb(database.connectionString); +const [company] = await db.insert(companies).values({ name: "Managed accounts test drive", issuePrefix: "AGG" }).returning(); +const userId = randomUUID(); +await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, membershipRole: "admin", status: "active" }); +const state = { arcadeAccounts: ["Work", "Personal"], executorAccounts: ["Work"], failed: false, partial: false, unsupported: false }; +const gatewayNames = { composio: ["COMPOSIO_MANAGE_CONNECTIONS", "COMPOSIO_SEARCH_TOOLS"], arcade: ["Notion.ListPages"], executor: ["integrations"], notion: ["notion-read"] }; +const connections = []; +for (const provider of ["notion", "composio", "arcade", "executor", "arcade"] as const) { + const optional = provider === "arcade" && connections.some(connection => connection.provider === "arcade"); + const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: optional ? "arcade optional" : provider, type: "mcp_http", metadata: { sourceTemplateKey: provider } }).returning(); + const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, uid: randomUUID(), name: optional ? "Arcade without sync" : provider === "notion" ? "Native workspace" : `Team ${provider[0].toUpperCase()}${provider.slice(1)}`, transport: "mcp_remote", authKind: "none", credentialPolicy: "shared", status: "active", enabled: true, createdByUserId: userId, + config: { sourceTemplateKey: provider, url: provider === "arcade" ? "https://api.arcade.dev/mcp/test" : `https://${provider}.example/mcp`, ...(provider === "executor" ? { managementUrl: "https://executor.sh/test-workspace/integrations" } : {}) } }).returning(); + await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "organization", status: "active", isDefault: true }); + await db.insert(toolCatalogEntries).values(gatewayNames[provider].map(toolName => ({ companyId: company.id, connectionId: connection.id, name: toolName, toolName, versionHash: "v1", status: "active", riskLevel: "read" as const }))); + connections.push({ ...connection, provider, optional }); +} +const remoteHttpRequest = async (rawUrl: string, init: RequestInit): Promise => { + const url = new URL(rawUrl); + const rpc = init.body ? JSON.parse(String(init.body)) : undefined; + const provider = url.hostname.startsWith("api.arcade") ? "arcade" : url.hostname.split(".")[0] as keyof typeof gatewayNames; + if (rpc?.method === "initialize") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: provider, version: "fixture" } } }); + if (rpc?.method === "notifications/initialized") return new Response(null, { status: 202 }); + if (rpc?.method === "tools/list") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { tools: (provider === "executor" && state.unsupported ? ["ping"] : gatewayNames[provider]).map(name => ({ name, inputSchema: { type: "object" }, annotations: { readOnlyHint: true } })) } }); + if (state.failed) return new Response("Fixture authorization expired", { status: 401 }); + if (provider === "arcade") { + if (url.pathname === "/v1/tools") return Response.json({ items: [{ qualified_name: "Notion.ListPages", toolkit: { name: "Notion" }, requirements: { met: true, authorization: { provider_id: "notion", token_status: "completed" } } }] }); + return Response.json({ items: state.arcadeAccounts.map(alias => ({ id: `arcade-${alias}`, user_id: "arcade-user", provider_id: "notion", connection_status: "active", provider_user_info: { email: alias } })), ...(state.partial ? { total: 100 } : {}) }); + } + if (provider === "composio") { + const args = rpc.params.arguments; + if (rpc.params.name === "COMPOSIO_SEARCH_TOOLS") return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { results: [] } } }); + const results = Object.fromEntries(args.toolkits.map((toolkit: { name: string }) => [toolkit.name, { toolkit: toolkit.name, accounts: toolkit.name === "notion" ? [{ id: "composio-Work", alias: "Work", status: "active", is_default: true }] : [] }])); + return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { data: { results } } } }); + } + return Response.json({ jsonrpc: "2.0", id: rpc.id, result: { structuredContent: { items: [...state.executorAccounts.map(alias => ({ integration: "notion", integrationName: "Notion", connection: alias, owner: "org", lastHealth: { status: "healthy", checkedAt: Date.now() } })), { integration: "internal-research", integrationName: "Internal research", connection: "Research", owner: "org", lastHealth: null }], hasMore: state.partial, nextOffset: state.partial ? 0 : null } } }); +}; +const actor = { actorType: "user" as const, actorId: userId }; +const svc = toolAccessService(db, { remoteHttpRequest, composioAppToolkits: ["notion"] }); +for (const connection of connections) { + if (connection.provider === "arcade" && !connection.optional) await svc.configureArcadeDiscovery(connection.id, { apiKey: "fixture-discovery-key", userId: "arcade-user" }, actor); + else if (["composio", "executor"].includes(connection.provider)) await svc.syncAggregatorApps(connection.id, true, actor); +} +const app = express(); +app.use((_req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "http://localhost:6200"); res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); next(); }); +app.options(/.*/, (_req, res) => { res.sendStatus(204); }); +app.use(express.json()); +app.get("/fixture", (_req, res) => res.json({ companyId: company.id, connections: connections.map(({ id, name, provider }) => ({ id, name, provider })) })); +app.post("/fixture", (req, res) => { Object.assign(state, req.body); res.json(state); }); +app.use((req, _res, next) => { req.actor = { type: "board", userId, source: "session", isInstanceAdmin: false, companyIds: [company.id], memberships: [{ companyId: company.id, membershipRole: "admin", status: "active" }] }; next(); }); +app.use("/api", toolAccessRoutes(db, { remoteHttpRequest })); +app.use(errorHandler); +const server = app.listen(4310, "127.0.0.1", () => { clearInterval(startup); console.log("Managed-account fixture API ready at http://localhost:4310/fixture"); }); +async function stop() { + server.close(); + fs.rmSync("/tmp/paperclip-aggregator-acceptance-db.json", { force: true }); + await database.cleanup(); + process.exit(0); +} +process.once("SIGINT", stop); +process.once("SIGTERM", stop); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts index 591ea51e20..34ceaf4390 100644 --- a/tests/e2e/connection-intents.spec.ts +++ b/tests/e2e/connection-intents.spec.ts @@ -175,10 +175,6 @@ if (!completion.ok) throw new Error(await completion.text()); `; } -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - test("AgentMail request shows a durable inline key card in agent chat", async ({ page, request }, testInfo) => { test.setTimeout(120_000); const settings = await json(await request.get("/api/instance/settings/experimental")); @@ -393,24 +389,21 @@ test("store setup and task connection intent share one fake provider through con const taskUrl = `/${seed.prefix}/issues/${issue.identifier}`; await page.goto(taskUrl); await expect( - page.getByText("Connection requester needs Notion"), + page.getByText(`Grant Connection requester access to “${connection.name}”?`), ).toBeVisible({ timeout: 30_000 }); - await page.getByRole("button", { name: "Connect / Use existing" }).click(); - await expect( - page.getByRole("heading", { name: "Use an existing connection" }), - ).toBeVisible(); - await page - .getByRole("button", { name: new RegExp(escapeRegExp(connection.name)) }) - .click(); + const permissions = page.getByRole("list", { name: "Tool permissions" }); + await expect(permissions.getByText("notion:list_pages", { exact: true })).toBeVisible(); + await expect(permissions.getByText("Allowed", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Grant access", exact: true }).click(); - await expect(page.getByText("Notion connected")).toBeVisible({ + await expect(page.getByText("Notion access granted")).toBeVisible({ timeout: 30_000, }); await expect(page).toHaveURL(new RegExp(`${taskUrl}$`)); await expect( page .getByTestId("connection-intent-focus-target") - .filter({ hasText: "Notion connected" }), + .filter({ hasText: "Notion access granted" }), ).toBeFocused(); expect(await page.locator("body").innerText()).not.toMatch( /\/authorize\?|authorizationUrl/, diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 0ed8fc231f..4b69efa919 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -4,6 +4,7 @@ import { tenantSessionRecovery, } from "@/lib/tenant-session-recovery"; import { __inflightGetCount, api, detachInflightGet } from "./client"; +import { toolsApi } from "./tools"; interface Deferred { promise: Promise; @@ -199,3 +200,20 @@ describe("per-caller abort semantics", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + + +describe("managed-account request isolation", () => { + it("does not share a previous viewing user's pending account response", async () => { + const previous = deferred(); + const current = deferred(); + fetchMock.mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise); + const previousUser = toolsApi.listAggregatorApps("shared-gateway"); + const currentUser = toolsApi.listAggregatorApps("shared-gateway"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.every(([, options]) => options.cache === "no-store")).toBe(true); + current.resolve(jsonResponse({ apps: ["current-user-account"] })); + previous.resolve(jsonResponse({ apps: ["previous-user-account"] })); + expect(await currentUser).toEqual({ apps: ["current-user-account"] }); + expect(await previousUser).toEqual({ apps: ["previous-user-account"] }); + }); +}); diff --git a/ui/src/api/tools.test.ts b/ui/src/api/tools.test.ts index 4d0a3fb0d3..c7082a1941 100644 --- a/ui/src/api/tools.test.ts +++ b/ui/src/api/tools.test.ts @@ -6,6 +6,7 @@ const mockApi = vi.hoisted(() => ({ vi.mock("./client", () => ({ api: mockApi, + detachInflightGet: vi.fn(), })); import { toolsApi } from "./tools"; diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 2e8a419cbd..cb09e0675c 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -1,3 +1,5 @@ +import type { AggregatorAppsResponse, ArcadeDiscoverySetupInput } from "@paperclipai/shared/aggregator-apps"; +import type { ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared"; import type { ToolApplication, ConfigureRailwaySsh, @@ -64,7 +66,7 @@ import type { ToolConnectionCreateCapabilities, ToolAppMetadataPreflightResult, } from "@paperclipai/shared"; -import { api } from "./client"; +import { api, detachInflightGet } from "./client"; /** * Tools & Access API client (Phase 6, PAP-10389). @@ -431,6 +433,23 @@ export const toolsApi = { api.get( `/tool-connections/${connectionId}/test-agents/${agentId}/access`, ), + setupComposioApp: (connectionId: string, toolkit: string, input: ComposioAppSetupInput) => + api.post(`/tool-connections/${connectionId}/composio/apps/${encodeURIComponent(toolkit)}/setup`, input), + listAggregatorApps: (connectionId: string) => { + const path = `/tool-connections/${connectionId}/aggregator/apps`; + // React Query already deduplicates within a viewing-user key. Path-only + // request coalescing could hand a previous user's in-flight response to a new account. + detachInflightGet(path); + return api.get(path, { cache: "no-store" }); + }, + syncAggregatorApps: (connectionId: string, force = false) => api.post(`/tool-connections/${connectionId}/aggregator/apps/sync`, { force }), + refreshAggregatorApps: (connectionId: string, toolkits: string[] = []) => api.post(`/tool-connections/${connectionId}/aggregator/apps/refresh`, { toolkits }), + configureArcadeDiscovery: (connectionId: string, input: ArcadeDiscoverySetupInput) => api.put(`/tool-connections/${connectionId}/aggregator/discovery`, input), + listComposioApps: (connectionId: string) => api.get(`/tool-connections/${connectionId}/composio/apps`), + syncComposioApps: (connectionId: string, force = false) => api.post(`/tool-connections/${connectionId}/composio/apps/sync`, { force }), + refreshComposioApps: (connectionId: string, toolkits: string[]) => api.post(`/tool-connections/${connectionId}/composio/apps/refresh`, { toolkits }), + manageComposioAppAccount: (connectionId: string, toolkit: string, input: ComposioAppAccountInput) => + api.post(`/tool-connections/${connectionId}/composio/apps/${encodeURIComponent(toolkit)}/accounts`, input), runTestCall: ( connectionId: string, input: { agentId: string; toolName: string; parameters?: Record }, diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 1983e2fc27..36dc305073 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -3684,6 +3684,17 @@ export function IssueThreadInteractionCard({ creatorLabel: createdByLabel, addresseeLabel, }); + if (interaction.kind === "connection_intent" && interaction.payload.accessRequest) { + return ( +
+ +
+ ); + } if (isToolAction && interaction.kind === "request_confirmation" && toolActionState) { return ( diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 83c37195be..8e168c10f2 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -43,6 +43,7 @@ const companyState = vi.hoisted(() => ({ const toastState = vi.hoisted(() => ({ pushToast: vi.fn(), })); +const navigateMock = vi.hoisted(() => vi.fn()); const mockIssuesApi = vi.hoisted(() => ({ create: vi.fn(), @@ -89,6 +90,11 @@ vi.mock("../context/ToastContext", () => ({ useToastActions: () => toastState, })); +vi.mock("../lib/router", async (importOriginal) => ({ + ...await importOriginal(), + useNavigate: () => navigateMock, +})); + vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, })); @@ -341,6 +347,7 @@ describe("NewIssueDialog", () => { dialogContentState.onEscapeKeyDown = null; dialogContentState.onPointerDownOutside = null; toastState.pushToast.mockReset(); + navigateMock.mockReset(); mockIssuesApi.create.mockReset(); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); @@ -1007,6 +1014,115 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("creates separate tasks with the same title and confirms each with a link", async () => { + dialogState.newIssueDefaults = { title: "Connect Circleback through Composio" }; + const { root, queryClient } = renderDialog(container); + await flush(); + const submit = () => Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task"))!; + + act(() => submit().click()); + await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); + const firstPayload = mockIssuesApi.create.mock.calls[0][1]; + expect(firstPayload).toMatchObject({ + title: "Connect Circleback through Composio", + allowDuplicate: true, + idempotencyKey: expect.any(String), + }); + expect(toastState.pushToast).toHaveBeenCalledWith({ + title: "Created PAP-2", + tone: "success", + action: { label: "Open PAP-2", href: "/PAP/issues/PAP-2" }, + }); + + dialogState.newIssueOpen = false; + act(() => root.render()); + dialogState.newIssueOpen = true; + act(() => root.render()); + await flush(); + act(() => submit().click()); + await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(2)); + const secondPayload = mockIssuesApi.create.mock.calls[1][1]; + expect(secondPayload.title).toBe(firstPayload.title); + expect(secondPayload.allowDuplicate).toBe(true); + expect(secondPayload.idempotencyKey).not.toBe(firstPayload.idempotencyKey); + act(() => root.unmount()); + }); + + it("creates a task on plain HTTP when randomUUID is unavailable", async () => { + const originalCrypto = globalThis.crypto; + vi.stubGlobal("crypto", { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) }); + try { + dialogState.newIssueDefaults = { title: "LAN preview task" }; + const { root } = renderDialog(container); + await flush(); + act(() => Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.includes("Create Task"))!.click()); + await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); + expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey) + .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + act(() => root.unmount()); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each([undefined, false, true])("navigates after creation only when navigateOnCreate is true (%s)", async (navigateOnCreate) => { + dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate }; + const { root } = renderDialog(container); + await flush(); + const submit = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task"))!; + act(() => submit.click()); + await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1)); + expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("navigateOnCreate"); + if (navigateOnCreate) { + expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/PAP/issues/PAP-2"); + expect(navigateMock.mock.invocationCallOrder[0]).toBeGreaterThan(dialogState.closeNewIssue.mock.invocationCallOrder[0]); + } else { + expect(navigateMock).not.toHaveBeenCalled(); + } + act(() => root.unmount()); + }); + + it("navigates to the returned task ID when it has no identifier", async () => { + dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate: true }; + mockIssuesApi.create.mockResolvedValue({ id: "created-task", companyId: "company-1", identifier: null }); + const { root } = renderDialog(container); + await flush(); + act(() => Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task"))!.click()); + await waitForAssertion(() => expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/PAP/issues/created-task")); + act(() => root.unmount()); + }); + + it("reuses a create request key after a failure until the submitted draft changes", async () => { + dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate: true }; + mockIssuesApi.create.mockRejectedValue(new Error("Request failed")); + const { root } = renderDialog(container); + await flush(); + const submit = () => Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("Create Task"))!; + + act(() => submit().click()); + await waitForAssertion(() => expect(container.textContent).toContain("Request failed")); + const firstPayload = mockIssuesApi.create.mock.calls[0][1]; + expect(dialogState.closeNewIssue).not.toHaveBeenCalled(); + expect(toastState.pushToast).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalled(); + + act(() => submit().click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(2)); + await flush(); + expect(mockIssuesApi.create.mock.calls[1][1].idempotencyKey).toBe(firstPayload.idempotencyKey); + + await typeTextareaValue(container.querySelector('textarea[placeholder="Task title (optional)"]')!, "Connect another app"); + act(() => submit().click()); + await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(3)); + expect(mockIssuesApi.create.mock.calls[2][1].idempotencyKey).not.toBe(firstPayload.idempotencyKey); + act(() => root.unmount()); + }); + it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => { const title = "验证中文任务"; const description = [ diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 6ffaf74692..36ef947261 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -17,6 +17,7 @@ import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; +import { useNavigate } from "../lib/router"; import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { defaultExecutionWorkspaceModeForProject, @@ -29,6 +30,7 @@ import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from " import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { recordRecentTask } from "../lib/recent-tasks"; import { buildExecutionPolicy } from "../lib/issue-execution-policy"; +import { createUuid } from "../lib/uuid"; import { isIssueWorkMode, nextWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; import { useToastActions } from "../context/ToastContext"; import { @@ -486,6 +488,7 @@ export function NewIssueDialog() { const workModeOptions = useMemo(() => workModeMetaList(), []); const statuses = useMemo(() => buildStatusOptions(), []); const queryClient = useQueryClient(); + const navigate = useNavigate(); const { pushToast } = useToastActions(); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const [title, setTitle] = useState(""); @@ -522,6 +525,7 @@ export function NewIssueDialog() { const draftTimer = useRef | null>(null); const executionWorkspaceDefaultProjectId = useRef(null); const initializationKeyRef = useRef(null); + const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string } | null>(null); const effectiveCompanyId = dialogCompanyId ?? selectedCompanyId; const dialogCompany = companies.find((c) => c.id === effectiveCompanyId) ?? selectedCompany; @@ -636,8 +640,9 @@ export function NewIssueDialog() { mutationFn: async ({ companyId, stagedFiles: pendingStagedFiles, + navigateOnCreate, ...data - }: { companyId: string; stagedFiles: StagedIssueFile[] } & Record) => { + }: { companyId: string; stagedFiles: StagedIssueFile[]; navigateOnCreate?: boolean } & Record) => { const issue = await issuesApi.create(companyId, data); const failures: string[] = []; @@ -659,9 +664,9 @@ export function NewIssueDialog() { } } - return { issue, companyId, failures }; + return { issue, companyId, failures, navigateOnCreate }; }, - onSuccess: ({ issue, companyId, failures }) => { + onSuccess: ({ issue, companyId, failures, navigateOnCreate }) => { if (streamlinedUiEnabled) recordRecentTask(issue, currentUserId); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) }); @@ -669,21 +674,29 @@ export function NewIssueDialog() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); if (draftTimer.current) clearTimeout(draftTimer.current); + const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim(); + const issueRef = issue.identifier ?? issue.id; + const openIssueAction = prefix + ? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` } + : undefined; if (failures.length > 0) { - const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim(); - const issueRef = issue.identifier ?? issue.id; pushToast({ title: `Created ${issueRef} with upload warnings`, body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`, tone: "warn", - action: prefix - ? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` } - : undefined, + action: openIssueAction, + }); + } else { + pushToast({ + title: `Created ${issueRef}`, + tone: "success", + action: openIssueAction, }); } clearDraft(); reset(); closeNewIssue(); + if (navigateOnCreate) navigate(openIssueAction?.href ?? `/issues/${issueRef}`); }, }); @@ -976,6 +989,7 @@ export function NewIssueDialog() { }, []); function reset() { + createRequestRef.current = null; setIssueText("", ""); setStatus("todo"); setPriority(""); @@ -1072,7 +1086,7 @@ export function NewIssueDialog() { reviewerValues: reviewerValue ? [reviewerValue] : [], approverValues: approverValue ? [approverValue] : [], }); - createIssue.mutate({ + const createData = { companyId: effectiveCompanyId, stagedFiles, ...(currentTitle ? { title: currentTitle } : {}), @@ -1097,6 +1111,18 @@ export function NewIssueDialog() { ...(watchdogAgentId ? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } } : {}), + }; + // An explicit board create is a new task even when its title already exists. + // Reuse the request key only for retries of the same submitted draft. + const fingerprint = JSON.stringify(createData); + if (createRequestRef.current?.fingerprint !== fingerprint) { + createRequestRef.current = { fingerprint, idempotencyKey: createUuid() }; + } + createIssue.mutate({ + ...createData, + allowDuplicate: true, + idempotencyKey: createRequestRef.current.idempotencyKey, + navigateOnCreate: newIssueDefaults.navigateOnCreate === true, }); } diff --git a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx index e0e84d6c0f..ef5331bb98 100644 --- a/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx +++ b/ui/src/components/task-chat/TaskChatCompactInteractionCard.tsx @@ -1927,18 +1927,12 @@ export function TaskChatCompactInteractionCard({ if (interaction.kind === "connection_intent") { return ( - - ); } diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index 83ee435aaa..a7e4cbfb9a 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -18,6 +18,8 @@ interface NewIssueDefaults { assigneeUserId?: string; title?: string; description?: string; + /** Open the created task after submission; disabled unless the caller opts in. */ + navigateOnCreate?: boolean; } interface NewGoalDefaults { diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx index e82db5ccf9..44562e7bcf 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx @@ -767,3 +767,58 @@ describe("AgentMail inline setup", () => { expect(emailConnectMock).not.toHaveBeenCalled(); }); }); + +describe("embedded agent access request", () => { + const accessRequest = { + connectionId: "22222222-2222-4222-8222-222222222222", + connectionName: "Saved Composio", + tools: [ + { catalogEntryId: "33333333-3333-4333-8333-333333333333", toolName: "COMPOSIO_SEARCH_TOOLS", versionHash: "v1", permission: "allowed" as const }, + { catalogEntryId: "44444444-4444-4444-8444-444444444444", toolName: "COMPOSIO_MANAGE_CONNECTIONS", versionHash: "v1", permission: "ask_first" as const }, + ], + }; + const interaction: ConnectionIntentInteraction = { ...pendingConnectionIntentInteraction, payload: { ...pendingConnectionIntentInteraction.payload, serviceName: "Composio", accessRequest } }; + + it("shows the exact tool permissions before granting inline without a setup modal", async () => { + setupOptionsMock.mockResolvedValue({ canGrantAccess: true }); + getAgentMock.mockResolvedValue({ id: interaction.payload.requestingAgentId, name: "Researcher" }); + completeMock.mockResolvedValue({ ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: accessRequest.connectionId } }); + renderBody(interaction); + await waitForAssertion(() => expect(button("Grant access")?.disabled).toBe(false)); + expect(document.body.textContent).toContain("Grant Researcher access to “Saved Composio”?"); + expect(document.body.querySelector('[data-slot="agent-avatar"]')?.getAttribute("aria-label")).toBe("Researcher"); + expect(Array.from(document.body.querySelectorAll('ul[aria-label="Tool permissions"] li'), row => row.textContent)).toEqual([ + "COMPOSIO_SEARCH_TOOLSAllowed", + "COMPOSIO_MANAGE_CONNECTIONSAsk first", + ]); + expect(completeMock).not.toHaveBeenCalled(); + await act(() => button("Grant access")?.click()); + await waitForAssertion(() => expect(completeMock).toHaveBeenCalledWith(interaction.id, accessRequest.connectionId)); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("keeps errors visible and does not allow a non-manager to grant", async () => { + setupOptionsMock.mockResolvedValue({ canGrantAccess: false }); + renderBody(interaction); + await waitForAssertion(() => expect(document.body.textContent).toContain("Connection manager required")); + expect(button("Grant access")?.disabled).toBe(true); + expect(completeMock).not.toHaveBeenCalled(); + }); + + it("supports declining and displays permission errors on the card", async () => { + setupOptionsMock.mockResolvedValue({ canGrantAccess: true }); + completeMock.mockRejectedValue(new Error("Tools changed. Request access again.")); + renderBody(interaction); + await waitForAssertion(() => expect(button("Grant access")?.disabled).toBe(false)); + await act(() => button("Grant access")?.click()); + await waitForAssertion(() => expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("Tools changed")); + await act(() => button("Not now")?.click()); + await waitForAssertion(() => expect(declineMock).toHaveBeenCalledWith(interaction.id)); + }); + + it("shows resolved access and does not expose controls to other users", () => { + renderBody({ ...interaction, status: "accepted", result: { version: 1, outcome: "connected", connectionId: accessRequest.connectionId } }); + expect(document.body.textContent).toContain("Composio access granted"); + expect(button("Grant access")).toBeUndefined(); + }); +}); diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 3a7f50c9db..79074ff0fd 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -16,6 +16,7 @@ import { agentsApi } from "@/api/agents"; import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { defaultAiConnectionName } from "@/components/ai-connections/model"; import { AppLogo } from "@/pages/apps/AppLogo"; +import { AgentAvatar } from "@/components/AgentAvatar"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -63,6 +64,12 @@ export function ConnectionIntentInteractionBody({ const isPending = interaction.status === "pending"; const isAi = interaction.payload.purpose === "ai"; const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail"; + const accessRequest = interaction.payload.accessRequest; + const agentQuery = useQuery({ + queryKey: ["agents", "detail", interaction.payload.requestingAgentId, interaction.companyId], + queryFn: () => agentsApi.get(interaction.payload.requestingAgentId, interaction.companyId), + enabled: Boolean(accessRequest) && isPending, + }); const focusTargetId = `connection-intent-focus-target-${interaction.id}`; const invalidateTask = async ( @@ -233,14 +240,14 @@ export function ConnectionIntentInteractionBody({ interaction.status === "accepted" ? { icon: CheckCircle2, - title: interaction.payload.upstreamService ? "External provider connected" : `${interaction.payload.serviceName} connected`, - body: interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, + title: accessRequest ? `${interaction.payload.serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${interaction.payload.serviceName} connected`, + body: accessRequest ? null : interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, } : interaction.status === "rejected" ? { icon: XCircle, - title: "Connection declined", - body: isAi ? "The task still needs a working AI connection before it can run." : `${interaction.payload.requestingAgentName} was notified and can continue without it.`, + title: accessRequest ? "Access declined" : "Connection declined", + body: accessRequest ? null : isAi ? "The task still needs a working AI connection before it can run." : `${interaction.payload.requestingAgentName} was notified and can continue without it.`, } : interaction.status === "expired" ? { @@ -272,7 +279,7 @@ export function ConnectionIntentInteractionBody({

{status.title}

-

{status.body}

+ {status.body ?

{status.body}

: null}
@@ -296,10 +303,10 @@ export function ConnectionIntentInteractionBody({

Waiting for {addresseeLabel}

-

+ {!accessRequest ?

Only the addressed person can choose an identity or authorize this connection. -

+

: null} @@ -309,6 +316,35 @@ export function ConnectionIntentInteractionBody({ const needsRetry = interaction.payload.phase === "needs_retry"; const authorizing = interaction.payload.phase === "authorizing"; + if (accessRequest) { + const busy = completeMutation.isPending || declineMutation.isPending; + return
+
+
+ +
+

Grant {interaction.payload.requestingAgentName} access to “{accessRequest.connectionName}”?

+
+ +
+
    + {accessRequest.tools.map(tool =>
  • + {tool.toolName} + {tool.permission === "allowed" ? "Allowed" : "Ask first"} +
  • )} +
+ {setupQuery.isError || completeMutation.isError || declineMutation.isError ?

{(completeMutation.error ?? declineMutation.error ?? setupQuery.error)?.message ?? "Couldn’t update this access request."}

: null} + {setupQuery.data?.canGrantAccess === false ?

Connection manager required.

: null} +
+ + +
+
+
; + } + const repair = setupQuery.data?.aiRepair; const selectedReady = repair && setupQuery.data?.existingConnections.some((connection) => connection.id === repair.connection.id); const readyForAdoption = setupQuery.data?.aiConnectionRequiresAdoption diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 548d4a8681..d6dd4014e4 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +1,5 @@ import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup"; +import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { ConnectionChoiceList } from "./ConnectionChoiceList"; @@ -554,7 +555,8 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { } if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault" && isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) { - return ; + const target = findAggregatorApp(provider, searchParams.get("targetToolkit")); + return ; } return ; } @@ -4390,7 +4392,7 @@ export function connectionDefaultSummarySentence(input: { } /** - * The stated default plus the collapsed Advanced disclosure that replaced the + * The stated default plus the collapsed Change disclosure that replaced the * Access step. It sits in the same place on every connector and never blocks * the primary action: opening it is optional, and everything inside it can * also be changed on the Permissions tab after connecting. @@ -4428,38 +4430,30 @@ export function ConnectionAccessDefaults({ const [open, setOpen] = useState(false); const expanded = open || forceOpen; return ( -
+

{sentence}

- +
{(notice ?? []).map((reason) => (

{reason}

))} - - - {expanded ? - -
- {extra} - {agents - ? {}} onContinue={() => {}} /> - : {}} onContinue={() => {}} />} -
-
-
-
+ +
+ {extra} + {agents + ? {}} onContinue={() => {}} /> + : {}} onContinue={() => {}} />} +
+
+ ); } diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx index a0cd9ece82..8f7cb10705 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx @@ -6,6 +6,7 @@ import { SetupWizardFooter } from "@/components/SetupWizard"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { RemoteMcpManagement } from "./RemoteMcpManagement"; import { @@ -53,6 +54,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent const uid = useId(); const heading = useRef(null); const previousStep = useRef(s.step); + const [sessionOpen, setSessionOpen] = useState(() => Boolean(provider.defaultUrl && s.url !== provider.defaultUrl)); useEffect(() => { if (previousStep.current !== s.step) heading.current?.focus(); previousStep.current = s.step; @@ -113,6 +115,15 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent : s.connectStatus === "rejected" ? { title: "Credentials were rejected", body: `Check or replace the credentials from ${provider.name}, then reconnect. Your agent access and tool choices are preserved.` } : s.connectStatus === "unreachable" ? { title: "Paperclip could not reach this server", body: "Check that the endpoint is running and reachable from Paperclip, then try again. Your draft is still here." } : null; + const urlField =
+
{provider.urlHelp}
+ change({ url: event.target.value })} /> +

{provider.urlHelp}

+ {provider.id === "executor" ?
+ The URL of your Executor organization’s integrations page. Used to open and manage imported accounts.
+ change({ managementUrl: event.target.value })} /> +
: null} +
; return
= 0 && !s.setupComplete ? `Paperclip will use ${provider.name} on your behalf.` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`} step={currentStep >= 0 && !s.setupComplete ? "key" : "gallery"} activeIndex={currentStep} labels={steps.map(() => "Connect")} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : onCancel ?? a.saveExit} />
- {upstreamServiceName && {provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.} + {upstreamServiceName && {provider.name} handles the connection and requests to {upstreamServiceName}. {provider.id === "composio" ? "After connecting this account, sign in to the app in Composio." : "Manage app sign-in in the provider, then refresh your gateway here."}} {s.notice &&

{s.notice}

} {s.step === "access" && { if (grantKind !== "agent") change({ grantKind }); }} @@ -154,11 +165,10 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent {error &&
{error.body}
} {s.connectStatus === "cancelled" &&

Connection cancelled. Your setup details are preserved; try again when you are ready.

}
-
-
{provider.urlHelp}
- change({ url: event.target.value })} /> -

{provider.urlHelp}

-
+ {provider.defaultUrl ? + + {urlField} + : urlField} {defaults(

Authentication

{provider.authHelp}

diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx index ee5fc9c746..0bd2c28c77 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx @@ -1,12 +1,15 @@ import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared"; +import { isAppAggregator, aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps"; import { askFirstCatalogEntryIdsFor } from "../connection-defaults"; import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice"; import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow"; import { agentsApi } from "@/api/agents"; import { toolsApi } from "@/api/tools"; +import { resolveAccountUserId } from "@/api/companies-query"; import { useCompany } from "@/context/CompanyContext"; +import { findAggregatorApp } from "@paperclipai/shared/aggregator-app-catalog"; import { useNavigate, useSearchParams } from "@/lib/router"; import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; @@ -30,6 +33,13 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); + let targetToolkit = searchParams.get("targetToolkit"); + if (!targetToolkit && connection && host === "page") { + try { targetToolkit = sessionStorage.getItem(`paperclip:mcp-upstream-app:${selectedCompanyId}:${connection.id}`); } catch { /* Storage may be disabled. */ } + } + const targetApp = findAggregatorApp(providerId, targetToolkit); + const targetRoute = targetApp?.routes.find((route) => route.provider === providerId); + upstreamServiceName ??= targetApp?.name; const oauthOutcome = connection ? searchParams.get("oauth") : null; const queries = useQueryClient(); const accessDraftKey = `paperclip:mcp-access-draft:${selectedCompanyId}:${interactionId || providerId}`; @@ -52,6 +62,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" step: "connect", grantKind: connection ? connection.credentialPolicy === "per_user" ? "user" : "organization" : requestedAgentId ? "user" : "organization", setupComplete: Boolean(connection && connection.status !== "draft"), url: typeof connection?.config?.url === "string" ? connection.config?.url : provider.defaultUrl, + managementUrl: typeof connection?.config?.managementUrl === "string" ? connection.config.managementUrl : "", auth: connection?.config?.mcpAuthMode === "bearer" ? "bearer" : connection?.authKind === "api_key" ? "headers" : provider.supportsBrowserAuth ? "auto" : "none", token: "", headers: [], connectStatus: oauthOutcome === "denied" ? "cancelled" : oauthOutcome === "failed" ? "oauth_failed" : "idle", connected: false, identity: null, allAgents: true, agentIds: [], permissions: {}, tools: [], notice: connection?.authKind === "api_key" ? "Saved credentials are retained when these fields are left blank. Enter a replacement only to change them." : null, refreshing: false, @@ -89,7 +100,23 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" const finish = async (id: string) => { try { sessionStorage.removeItem(accessDraftKey); } catch { /* Storage can be disabled. */ } await queries.invalidateQueries({ queryKey: ["tools"] }); + if (isAppAggregator(providerId)) { + // Account inventory is an observation through this human's gateway, not a setup task. + void resolveAccountUserId(queries).then(userId => { + const key = queryKeys.tools.aggregatorApps(id, userId); + return queries.fetchQuery({ queryKey: [...key, "sync"], queryFn: () => toolsApi.syncAggregatorApps(id), staleTime: 0 }) + .then(result => queries.setQueryData(key, result)); + }).catch(() => undefined); + } if (onComplete) onComplete({ connectionId: id }); + else if (providerId === "composio") { + try { sessionStorage.removeItem(`paperclip:mcp-upstream-app:${selectedCompanyId}:${id}`); } catch { /* Storage may be disabled. */ } + navigate(`/apps/${id}/permissions`); + } + else if (host === "page" && targetApp && targetRoute) { + try { sessionStorage.removeItem(`paperclip:mcp-upstream-app:${selectedCompanyId}:${id}`); } catch { /* Storage may be disabled. */ } + navigate("/apps"); + } else if (isAppAggregator(providerId)) navigate("/apps"); else navigate(`/apps/${id}/permissions`); }; const submit = async (saveDraft = false) => { @@ -101,7 +128,9 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" // Reserve the window while handling the click so popup blockers do not // discard the later OAuth response. URL/token-only providers never need it. if (!saveDraft && host === "dialog" && state.auth === "auto" && (!popup.current || popup.current.closed)) { - popup.current = window.open("about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes"); + try { + popup.current = window.open("about:blank", "paperclip-connection-oauth", "popup,width=720,height=760,resizable=yes,scrollbars=yes"); + } catch { popup.current = null; } } busy.current = true; edit({ connectStatus: "connecting", notice: null }); @@ -115,6 +144,9 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" credentials[`headers.${header.name.trim()}`] = header.value; } } + const managementUrl = providerId === "executor" && state.managementUrl?.trim() + ? aggregatorManagementUrl("executor", state.managementUrl.trim()) : null; + if (providerId === "executor" && state.managementUrl?.trim() && !managementUrl) throw new Error("Use an HTTPS console URL without credentials."); const prior = savedConnection.current; const result = await toolsApi.connectApp(selectedCompanyId, { galleryKey: providerId, connectionMethodKey: REMOTE_MCP_CONNECTOR_METHODS[providerId], @@ -123,7 +155,13 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" credentialValues: credentials, saveDraft, ...(prior ? prior.status === "draft" ? { resumeConnectionId: prior.id } : { reconnectConnectionId: prior.id } : {}), }); + if (managementUrl) { + result.connection = await toolsApi.updateConnection(result.connectionId, { config: { ...result.connection.config, managementUrl } }); + } savedConnection.current = result.connection; + if (host === "page" && targetRoute) { + try { sessionStorage.setItem(`paperclip:mcp-upstream-app:${selectedCompanyId}:${result.connectionId}`, targetRoute.toolkit); } catch { /* Storage may be disabled. */ } + } if (interactionId) { try { sessionStorage.setItem(intentDraftKey, result.connectionId); } catch { /* Storage may be disabled. */ } } @@ -141,12 +179,18 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" if (!target.ok) throw new Error(target.message); authorizationUrl.current = target.url; edit({ connectStatus: "sign_in", token: "", headers: [] }); - if (host === "dialog") { - if (popup.current && !popup.current.closed) { + try { + if (host === "dialog") { + if (!popup.current || popup.current.closed) throw new Error("Sign-in window unavailable"); popup.current.location.assign(target.url); popup.current.focus(); - } - } else navigateTopLevel(target.url); + } else navigateTopLevel(target.url); + } catch { + // The connection and OAuth session already exist. Preserve them and + // let the native sign-in link recover a blocked browser handoff. + edit({ notice: "Paperclip couldn’t open sign-in. Use the sign-in link below to continue." }); + onPhaseChange?.("needs_retry"); + } return; } popup.current?.close(); @@ -176,7 +220,13 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" edit, navigate: (step) => edit({ step }), connect: () => { void submit(); }, cancelConnect: () => { if (!busy.current) { popup.current?.close(); popup.current = null; edit({ connectStatus: "cancelled" }); onPhaseChange?.("needs_retry"); } }, openProvider: (purpose) => { - if (purpose === "sign_in" && authorizationUrl.current) { if (host === "dialog") { popup.current = null; edit({ connectStatus: "sign_in" }); } else navigateTopLevel(authorizationUrl.current); } + if (purpose === "sign_in" && authorizationUrl.current) { + // The real anchor owns navigation, including in embedded browsers. + // Do not also redirect the board or open a second scripted window. + popup.current = null; + edit({ connectStatus: "sign_in", notice: null }); + onPhaseChange?.("authorizing"); + } else window.open(provider.setupUrl, "_blank", "noopener,noreferrer"); }, saveExit: () => { @@ -198,5 +248,5 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" if (connection && !installs.data) return

{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}

{installs.isError && }
; // Header Cancel abandons unsaved input, including invalid URLs. The separate // Save & exit action persists a resumable draft through actions.saveExit. - return navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />; + return navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={authorizationUrl.current} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />; } diff --git a/ui/src/features/connections/remote-mcp/providers.ts b/ui/src/features/connections/remote-mcp/providers.ts index 283c55c373..59b39c0620 100644 --- a/ui/src/features/connections/remote-mcp/providers.ts +++ b/ui/src/features/connections/remote-mcp/providers.ts @@ -43,7 +43,7 @@ export const remoteMcpProviders: Record setupUrl: "https://docs.composio.dev/docs/composio-connect", dashboardUrl: "https://dashboard.composio.dev", defaultUrl: "https://connect.composio.dev/mcp", placeholder: "https://connect.composio.dev/mcp", - urlHelp: "Composio Connect is prefilled. For an externally configured session, replace this with its MCP URL and add its supplied headers under Advanced authentication.", + urlHelp: "Paste the MCP URL from your existing Composio session. If it requires headers, add them under Change.", authHelp: "Session URLs and headers come from your external Composio setup. Direct-tools sessions expose individual actions.", }, executor: { diff --git a/ui/src/features/connections/remote-mcp/types.ts b/ui/src/features/connections/remote-mcp/types.ts index ebcea37910..3fb719d443 100644 --- a/ui/src/features/connections/remote-mcp/types.ts +++ b/ui/src/features/connections/remote-mcp/types.ts @@ -12,6 +12,7 @@ export interface RemoteMcpSetupState { grantKind: "user" | "organization"; setupComplete: boolean; url: string; + managementUrl?: string; auth: "auto" | "bearer" | "headers" | "none"; token: string; headers: { id: string; name: string; value: string }[]; diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index 81bb314620..8eea3363cb 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -1622,3 +1622,22 @@ export const mixedIssueThreadInteractions = [ pendingRequestConfirmationInteraction, pendingAskUserQuestionsInteraction, ]; + + +export const pendingConnectionAccessInteraction = createConnectionIntentInteraction({ + id: "interaction-connection-access", + title: "Grant Composio access to Researcher?", + payload: { ...pendingConnectionIntentInteraction.payload, serviceSlug: "composio", serviceName: "Composio", + serviceLogoUrl: "/brands/apps/composio.svg", + accessRequest: { connectionId: "22222222-2222-4222-8222-222222222222", connectionName: "My Composio", + tools: [ + { catalogEntryId: "33333333-3333-4333-8333-333333333333", toolName: "COMPOSIO_SEARCH_TOOLS", versionHash: "fixture-v1", permission: "allowed" }, + { catalogEntryId: "44444444-4444-4444-8444-444444444444", toolName: "COMPOSIO_MANAGE_CONNECTIONS", versionHash: "fixture-v1", permission: "ask_first" }, + ], + }, + }, +}); +export const grantedConnectionAccessInteraction = createConnectionIntentInteraction({ + ...pendingConnectionAccessInteraction, id: "interaction-connection-access-granted", status: "accepted", + result: { version: 1, outcome: "connected", connectionId: pendingConnectionAccessInteraction.payload.accessRequest!.connectionId }, +}); diff --git a/ui/src/lib/aggregator-app-setup.test.ts b/ui/src/lib/aggregator-app-setup.test.ts new file mode 100644 index 0000000000..409cc7c0cd --- /dev/null +++ b/ui/src/lib/aggregator-app-setup.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { aggregatorAppSetupTask, aggregatorSetupAgent } from "./aggregator-app-setup"; + +function agent(id: string, overrides: Partial = {}): Agent { + return { id, name: id, role: "general", status: "active", reportsTo: null, createdAt: new Date("2026-01-01"), ...overrides } as Agent; +} + +describe("aggregator setup task assignee", () => { + it("prefers a named Default agent over other agents", () => { + expect(aggregatorSetupAgent([ + agent("ceo", { role: "ceo", createdAt: new Date(0) }), + agent("default", { name: " Default Agent ", reportsTo: "ceo" }), + ])?.id).toBe("default"); + }); + + it("chooses the highest ranking default and then the oldest at that rank", () => { + const agents = [ + agent("new", { name: "Default agent", createdAt: new Date("2026-09-01") }), + agent("report", { name: "Default agent", reportsTo: "old", createdAt: new Date(0) }), + agent("old", { name: "Default agent" }), + ]; + expect(aggregatorSetupAgent(agents)?.id).toBe("old"); + expect(aggregatorSetupAgent([...agents].reverse())?.id).toBe("old"); + }); + + it("falls back to leadership, then age and a stable ID when no default exists", () => { + expect(aggregatorSetupAgent([agent("engineer", { createdAt: new Date(0) }), agent("ceo", { role: "ceo" })])?.id).toBe("ceo"); + expect(aggregatorSetupAgent([agent("new", { createdAt: new Date("2026-09-01") }), agent("old")])?.id).toBe("old"); + expect(aggregatorSetupAgent([agent("b"), agent("a")])?.id).toBe("a"); + }); + + it("skips agents the task picker cannot assign", () => { + expect(aggregatorSetupAgent([ + agent("terminated", { name: "Default agent", status: "terminated" }), + agent("pending", { name: "Default agent", status: "pending_approval" }), + agent("invalid", { name: "Default agent", orgChainHealth: { status: "invalid_org_chain" } as Agent["orgChainHealth"] }), + agent("available"), + ])?.id).toBe("available"); + expect(aggregatorSetupAgent([])).toBeUndefined(); + }); + + it("preselects the chosen agent for either provider without mutating the roster", () => { + const agents = [agent("b"), agent("a")]; + for (const provider of ["composio", "arcade"] as const) { + expect(aggregatorAppSetupTask("Circleback", { provider, toolkit: "circleback", logoUrl: "https://example.com/logo.png", docsUrl: "https://example.com/docs" }, undefined, agents)).toMatchObject({ assigneeAgentId: "a", navigateOnCreate: true }); + } + expect(agents.map(({ id }) => id)).toEqual(["b", "a"]); + }); +}); diff --git a/ui/src/lib/aggregator-app-setup.ts b/ui/src/lib/aggregator-app-setup.ts new file mode 100644 index 0000000000..b55b41d5ad --- /dev/null +++ b/ui/src/lib/aggregator-app-setup.ts @@ -0,0 +1,52 @@ +import { aggregatorContinuationInstruction, type Agent, type ToolConnection } from "@paperclipai/shared"; +import type { AggregatorAppRoute } from "@paperclipai/shared/aggregator-app-catalog"; +import { isAgentTaskTarget } from "./company-members"; + +// Connect consumer accounts live in For You, separately from developer projects. +// Composio resolves the organization placeholder for the signed-in user. +export const COMPOSIO_APP_MANAGEMENT_URL = "https://dashboard.composio.dev/~/org/connect/apps"; + +type SetupAgent = Pick; + +export function aggregatorSetupAgent(agents: SetupAgent[]) { + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const depth = (agent: SetupAgent) => { + const seen = new Set([agent.id]); + let parent = agent.reportsTo; + while (parent && byId.has(parent) && !seen.has(parent)) { + seen.add(parent); + parent = byId.get(parent)!.reportsTo; + } + return seen.size - 1; + }; + const leadership: Partial> = { ceo: 0, cto: 1, cfo: 2, cmo: 3 }; + const createdAt = (agent: SetupAgent) => { + const time = new Date(agent.createdAt).getTime(); + return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER; + }; + const isDefault = (agent: SetupAgent) => agent.name.trim().toLowerCase() === "default agent"; + // Prefer the named default, then organizational rank, then the oldest hire. + return agents.filter(isAgentTaskTarget).sort((a, b) => + Number(isDefault(b)) - Number(isDefault(a)) + || depth(a) - depth(b) + || (leadership[a.role] ?? 4) - (leadership[b.role] ?? 4) + || createdAt(a) - createdAt(b) + || a.id.localeCompare(b.id), + )[0]; +} + +export function aggregatorAppSetupTask(appName: string, route: AggregatorAppRoute, account?: Pick, agents: SetupAgent[] = []) { + const providerName = route.provider === "composio" ? "Composio" : "Arcade"; + const accountInstruction = account ? ` Use the saved connection "${account.name}" (connection ID ${account.id}).` : ""; + const requiredAccess = route.provider === "composio" + ? "For Composio setup, request COMPOSIO_SEARCH_TOOLS as Allowed and COMPOSIO_MANAGE_CONNECTIONS as Ask first; request any additional tool only when it is needed." + : "For Arcade setup, identify and request only the discovery and app authorization tools needed for the requested app."; + const permissionInstruction = `Before using provider tools, call connections_search for ${providerName}, then connection_request with service "${route.provider}"${account ? `, connectionId "${account.id}"` : ""}${route.provider === "composio" ? ', and toolNames ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"]' : ", and the exact indexed toolNames needed for discovery and app authorization"}. If this agent lacks access, connection_request creates an embedded Grant access card for the human. ${requiredAccess} Wait for that card's recorded outcome and leave the task in_review while waiting. Do not replace it with a generic permission question or ask the human to change settings manually. Do not change permissions yourself or request access for all agents. After acceptance, verify the tools are available, then follow the provider steps below.`; + const agent = aggregatorSetupAgent(agents); + return { + navigateOnCreate: true, + ...(agent ? { assigneeAgentId: agent.id } : {}), + title: `Connect ${appName} through ${providerName}`, + description: `Help me connect ${appName} through ${providerName}, using my existing ${providerName} gateway.${accountInstruction} The provider toolkit is ${route.toolkit}. Do not switch providers or create another gateway.\n\nHandle this connection setup directly. Do not hire agents or delegate the setup. If a human needs to sign in or authorize access, provide the browser link and wait for them.\n\n${permissionInstruction}\n\n${aggregatorContinuationInstruction(route.provider, appName)}`, + }; +} diff --git a/ui/src/lib/issue-execution-policy.ts b/ui/src/lib/issue-execution-policy.ts index 38313d7364..83a1d3d3cb 100644 --- a/ui/src/lib/issue-execution-policy.ts +++ b/ui/src/lib/issue-execution-policy.ts @@ -1,36 +1,9 @@ import type { IssueExecutionPolicy, IssueExecutionStageParticipant, IssueExecutionStagePrincipal } from "@paperclipai/shared"; import { parseAssigneeValue } from "./assignees"; +import { createUuid as newId } from "./uuid"; type StageType = "review" | "approval"; -function newId() { - const webCrypto = globalThis.crypto; - if (typeof webCrypto?.randomUUID === "function") { - return webCrypto.randomUUID(); - } - - const bytes = new Uint8Array(16); - if (typeof webCrypto?.getRandomValues === "function") { - webCrypto.getRandomValues(bytes); - } else { - for (let index = 0; index < bytes.length; index += 1) { - bytes[index] = Math.floor(Math.random() * 256); - } - } - - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); - return [ - hex.slice(0, 4).join(""), - hex.slice(4, 6).join(""), - hex.slice(6, 8).join(""), - hex.slice(8, 10).join(""), - hex.slice(10, 16).join(""), - ].join("-"); -} - function principalKey(principal: IssueExecutionStagePrincipal | IssueExecutionStageParticipant) { return principal.type === "agent" ? `agent:${principal.agentId}` : `user:${principal.userId}`; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 3845d43642..6a961016db 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -47,6 +47,8 @@ export const queryKeys = { activity: (endpointId: string) => ["chat-endpoints", endpointId, "activity"] as const, }, tools: { + aggregatorApps: (connectionId: string, userId: string | null) => ["tools", "aggregator-apps", connectionId, userId] as const, + composioApps: (connectionId: string) => ["tools", "composio-apps", connectionId] as const, applications: (companyId: string) => ["tools", companyId, "applications"] as const, connections: (companyId: string) => diff --git a/ui/src/lib/uuid.ts b/ui/src/lib/uuid.ts new file mode 100644 index 0000000000..356b65524a --- /dev/null +++ b/ui/src/lib/uuid.ts @@ -0,0 +1,13 @@ +/** Browser UUIDs also work on plain-HTTP LAN and tailnet previews. */ +export function createUuid(): string { + const webCrypto = globalThis.crypto; + if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID(); + + const bytes = new Uint8Array(16); + if (typeof webCrypto?.getRandomValues === "function") webCrypto.getRandomValues(bytes); + else for (let index = 0; index < bytes.length; index++) bytes[index] = Math.floor(Math.random() * 256); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")); + return [hex.slice(0, 4).join(""), hex.slice(4, 6).join(""), hex.slice(6, 8).join(""), hex.slice(8, 10).join(""), hex.slice(10).join("")].join("-"); +} diff --git a/ui/src/pages/apps/AggregatorAppManager.tsx b/ui/src/pages/apps/AggregatorAppManager.tsx new file mode 100644 index 0000000000..03099b424f --- /dev/null +++ b/ui/src/pages/apps/AggregatorAppManager.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink } from "lucide-react"; +import type { ToolConnection } from "@paperclipai/shared"; +import { AGGREGATOR_NAMES, aggregatorManagementUrl, isAppAggregator } from "@paperclipai/shared/aggregator-apps"; +import type { AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; +import { toolsApi } from "@/api/tools"; +import { useAccountIdentity } from "@/api/companies-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export function AggregatorAppManager({ app, connections, initialConnectionId, onClose }: { + app: AggregatorAppCatalogEntry; connections: ToolConnection[]; initialConnectionId?: string; onClose: () => void; +}) { + const [connectionId, setConnectionId] = useState(initialConnectionId ?? connections[0]?.id ?? ""); + const connection = connections.find(candidate => candidate.id === connectionId); + const provider = connection?.config?.sourceTemplateKey; + return { if (!open) onClose(); }}> + Manage {app.name} + Accounts and sign-in are managed in {isAppAggregator(provider) ? AGGREGATOR_NAMES[provider] : "the provider"}. + + {connections.length > 1 ?
+
: null} + {connection ? :

This connection is no longer available.

} +
; +} + +function AccountObservations({ app, connection, onClose }: { app: AggregatorAppCatalogEntry; connection: ToolConnection; onClose: () => void }) { + const queries = useQueryClient(); + const { userId, settled } = useAccountIdentity(); + const key = queryKeys.tools.aggregatorApps(connection.id, userId); + const query = useQuery({ queryKey: key, enabled: settled, queryFn: () => toolsApi.listAggregatorApps(connection.id), refetchInterval: query => query.state.data?.sync.status === "syncing" ? 1500 : false }); + const snapshots = (settled ? query.data?.apps : undefined)?.filter(snapshot => snapshot.appSlug === app.slug) ?? []; + const accounts = snapshots.flatMap(snapshot => snapshot.accounts.map(account => ({ account, snapshot }))); + const refresh = useMutation({ mutationFn: async () => { const result = await toolsApi.refreshAggregatorApps(connection.id, snapshots.map(snapshot => snapshot.toolkit)); queries.setQueryData(key, result); } }); + const provider = query.data?.provider; + const name = provider ? AGGREGATOR_NAMES[provider] : "provider"; + const managementUrl = provider ? aggregatorManagementUrl(provider, accounts[0]?.account.managementUrl ?? (typeof connection.config?.managementUrl === "string" ? connection.config?.managementUrl : null)) : null; + return <> + {query.isError || refresh.isError || query.data?.sync.status === "error" ?

Couldn’t check {name}. Last known accounts are shown.

: null} + {query.isLoading ?

Loading accounts…

: accounts.length ?
+ {accounts.map(({ account, snapshot }) =>
+

{account.alias || `${app.name} account`}

+

{snapshot.freshness === "stale" || snapshot.errorAt || Date.now() - new Date(snapshot.checkedAt).getTime() > 5 * 60_000 || account.status === "UNVERIFIED" ? "Not verified" : account.status === "ACTIVE" ? "Connected" : account.status === "INITIATED" ? "Waiting for sign-in" : "Needs sign-in"}

+
)} +
:

{query.data?.discovery.message ?? `No connected ${app.name} accounts.`}

} +

Via “{connection.name}”

+
+ + {managementUrl ? : null} +
+ ; +} diff --git a/ui/src/pages/apps/AggregatorConnectDialog.tsx b/ui/src/pages/apps/AggregatorConnectDialog.tsx new file mode 100644 index 0000000000..c1bfe63eb7 --- /dev/null +++ b/ui/src/pages/apps/AggregatorConnectDialog.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps"; +import { Label } from "@/components/ui/label"; +import { ExternalLink } from "lucide-react"; +import type { AggregatorAppCatalogEntry, AggregatorAppRoute } from "@paperclipai/shared/aggregator-app-catalog"; +import { isToolConnectionAttentionHealth, type ToolConnection } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { AppLogo } from "./AppLogo"; +import { remoteMcpProviders } from "@/features/connections/remote-mcp/providers"; +import { ComposioAppSetup } from "./ComposioAppSetup"; + +export function aggregatorAppConnectHref(route: AggregatorAppRoute, connection?: ToolConnection) { + const params = new URLSearchParams({ source: route.provider, targetToolkit: route.toolkit }); + if (connection) params.set(connection.status === "draft" ? "resume" : "reconnect", connection.id); + return `/apps/connect?${params}`; +} + +export function AggregatorConnectDialog({ app, connections, initialProvider, onClose, onNavigate }: { + app: AggregatorAppCatalogEntry; + connections: Partial>; + initialProvider?: AggregatorAppRoute["provider"]; + onClose: () => void; + onNavigate: (href: string) => void; +}) { + const [selectedAccountId, setSelectedAccountId] = useState(""); + const [selected, setSelected] = useState(() => app.routes.find(route => route.provider === initialProvider) ?? (app.routes.length === 1 ? app.routes[0] : null)); + const canChooseProvider = app.routes.length > 1; + const existing = selected ? connections[selected.provider] ?? [] : []; + const usableConnection = existing.find((connection) => connection.status === "active" && connection.enabled && !isToolConnectionAttentionHealth(connection.healthStatus)); + const account = existing.find(connection => connection.id === selectedAccountId) ?? usableConnection ?? existing[0]; + const managementUrl = selected ? aggregatorManagementUrl(selected.provider, typeof account?.config?.managementUrl === "string" ? account.config.managementUrl : null) : null; + const provider = selected ? remoteMcpProviders[selected.provider] : null; + function choose(route: AggregatorAppRoute) { + const accounts = connections[route.provider] ?? []; + if (accounts.length === 0) { onNavigate(aggregatorAppConnectHref(route)); onClose(); } + else setSelected(route); + } + return { if (!open) onClose(); }}> + + + {provider ? `Connect ${app.name} through ${provider.name}` : `Connect ${app.name}`} + {selected?.provider === "composio" + ? `Choose a saved Composio account or connect a new one to use ${app.name}.` + : provider + ? `Your ${provider.name} gateway is already saved. ${app.name} authorization is managed inside ${provider.name}.` + : "Which service would you like to use? This service handles the connection and requests to this app."} + + {!selected ?
{[...app.routes].sort((a, b) => Number(Boolean(connections[b.provider]?.length)) - Number(Boolean(connections[a.provider]?.length))).map((route) => { + const accounts = connections[route.provider] ?? []; + const name = remoteMcpProviders[route.provider].name; + const status = accounts.some((connection) => connection.status === "active" && connection.enabled && !isToolConnectionAttentionHealth(connection.healthStatus)) + ? "Already connected" : accounts.some((connection) => connection.status === "draft") ? "Setup incomplete" : accounts.length ? "Needs attention" : "Set up a connection"; + return ; + })}
:
+ {selected.provider !== "composio" ?
+ +
: null} + {selected.provider === "composio" ? connection.status === "active" && connection.enabled && !isToolConnectionAttentionHealth(connection.healthStatus))} onClose={onClose} + onBack={canChooseProvider ? () => setSelected(null) : undefined} onConnectNew={() => { onNavigate(`${aggregatorAppConnectHref(selected)}&new=1`); onClose(); }} /> : null} + +
} + {selected?.provider !== "composio" ? + + {selected ?
+ {managementUrl ? : null} + +
: null} +
: null} +
+
; +} diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 767c24eed3..584c645ac0 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -6,6 +6,8 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getAppStoreDefinition } from "@paperclipai/shared"; +import type { AggregatorAppSnapshot, AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps"; +import { queryKeys } from "@/lib/queryKeys"; import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return"; import { AppDetail } from "./AppDetail"; import { APP_TABS } from "./app-tabs"; @@ -24,6 +26,7 @@ const listTestAgentsMock = vi.hoisted(() => vi.fn()); const getTestAgentAccessMock = vi.hoisted(() => vi.fn()); const updateConnectionMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); +const deleteProfileMock = vi.hoisted(() => vi.fn()); const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const refreshCatalogMock = vi.hoisted(() => vi.fn()); @@ -38,6 +41,8 @@ const replaceConnectionGrantMembersMock = vi.hoisted(() => vi.fn()); const startPersonalAuthorizationMock = vi.hoisted(() => vi.fn()); const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const getSessionMock = vi.hoisted(() => vi.fn()); +const listAggregatorAppsMock = vi.hoisted(() => vi.fn()); +const syncAggregatorAppsMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "permissions" as string | undefined })); const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() })); @@ -46,6 +51,8 @@ const navigateTopLevelMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ toolsApi: { + listAggregatorApps: (connectionId: string) => listAggregatorAppsMock(connectionId), + syncAggregatorApps: (connectionId: string, force: boolean) => syncAggregatorAppsMock(connectionId, force), getConnection: (connectionId: string) => getConnectionMock(connectionId), getConnectionInstalls: (connectionId: string) => getConnectionInstallsMock(connectionId), listApplications: (companyId: string) => listApplicationsMock(companyId), @@ -53,6 +60,7 @@ vi.mock("@/api/tools", () => ({ listConnections: (companyId: string) => listConnectionsMock(companyId), listCatalog: (connectionId: string) => listCatalogMock(connectionId), listProfiles: (companyId: string) => listProfilesMock(companyId), + deleteProfile: (profileId: string) => deleteProfileMock(profileId), listPolicies: (companyId: string) => listPoliciesMock(companyId), listConnectionActivity: (connectionId: string, limit: number) => listConnectionActivityMock(connectionId, limit), @@ -325,6 +333,20 @@ function setInputValue(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event("input", { bubbles: true })); } +function aggregatorApps(apps: AggregatorAppSnapshot[] = []): AggregatorAppsResponse { + return { + provider: "composio", apps, discovery: { availability: "available", message: null }, + sync: { status: "ready", coverage: "supported_catalog", checked: 2, total: 2, failed: 0, + lastCompletedAt: new Date().toISOString(), error: null }, + }; +} + +function observedApp(toolkit: string, name: string, overrides: Partial = {}): AggregatorAppSnapshot { + return { connectionId: "conn-1", provider: "composio", appSlug: toolkit, appName: name, toolkit, + status: "connected", freshness: "fresh", checkedAt: new Date().toISOString(), errorAt: null, + accounts: [{ id: `${toolkit}-account`, alias: null, status: "ACTIVE", isDefault: true }], ...overrides }; +} + describe("AppDetail", () => { let container: HTMLDivElement; let root: ReturnType; @@ -337,6 +359,8 @@ describe("AppDetail", () => { mockParams.tab = "permissions"; mockSearchParams.value = new URLSearchParams(); getConnectionMock.mockResolvedValue(connection()); + listAggregatorAppsMock.mockResolvedValue(aggregatorApps()); + syncAggregatorAppsMock.mockResolvedValue(aggregatorApps()); getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-1", applicationKey: "github", name: "GitHub", status: "active" }], @@ -414,6 +438,7 @@ describe("AppDetail", () => { listTestAgentsMock.mockResolvedValue({ agents: [] }); updateConnectionMock.mockResolvedValue(connection({ enabled: false })); finishAppMock.mockResolvedValue({}); + deleteProfileMock.mockResolvedValue({ deleted: true }); finalizeOAuthAccessMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); checkConnectionHealthMock.mockResolvedValue({ connection: connection(), healthStatus: "ok" }); @@ -455,8 +480,109 @@ describe("AppDetail", () => { ); }); await flushReact(); + return client; } + function useComposioConnection() { + getConnectionMock.mockResolvedValue(connection({ name: "Composio", authKind: "oauth", + config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" } })); + listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-1", applicationKey: "composio", name: "Composio", metadata: { sourceTemplateKey: "composio" } }] }); + } + + it("refreshes connected apps on first load, shows progress, and exposes the completed inventory", async () => { + useComposioConnection(); + const prior = aggregatorApps([observedApp("airtable", "Airtable")]); + const completed = aggregatorApps([observedApp("circleback-mcp", "Circleback")]); + listAggregatorAppsMock.mockResolvedValue(prior); + syncAggregatorAppsMock.mockResolvedValue({ ...prior, sync: { ...prior.sync, status: "syncing", checked: 0 } }); + const client = await renderAppDetail(); + await vi.waitFor(() => expect(container.textContent).toContain("Refreshing apps… 0 of 2")); + expect(syncAggregatorAppsMock).toHaveBeenCalledTimes(1); + expect(syncAggregatorAppsMock).toHaveBeenCalledWith("conn-1", true); + expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(true); + expect(container.textContent).not.toContain("Provider permissions come from your last sign-in"); + expect(container.textContent).not.toContain("Reconnect to update permissions"); + listAggregatorAppsMock.mockResolvedValue(completed); + await act(async () => { await client.refetchQueries({ queryKey: queryKeys.tools.aggregatorApps("conn-1", "user-1"), exact: true }); }); + await flushReact(); + const list = container.querySelector('ul[aria-label="Connected Composio apps"]')!; + expect(list.textContent).toContain("Circleback"); + expect(list.textContent).not.toContain("Airtable"); + expect(container.textContent).toContain("Apps refreshed."); + expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(false); + expect(syncAggregatorAppsMock).toHaveBeenCalledTimes(1); + }); + + it("manual refresh updates only this gateway and retains previous apps when it fails", async () => { + useComposioConnection(); + const observed = aggregatorApps([observedApp("notion", "Notion")]); + listAggregatorAppsMock.mockResolvedValue(observed); + syncAggregatorAppsMock.mockResolvedValue(observed); + await renderAppDetail(); + await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed.")); + expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(false); + syncAggregatorAppsMock.mockRejectedValueOnce(new Error("Authorization expired")); + await act(async () => container.querySelector('button[aria-label="Refresh Composio"]')!.click()); + await flushReact(); + await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Last known apps are shown.")); + expect(container.querySelector('ul[aria-label="Connected Composio apps"]')?.textContent).toContain("NotionNot verified"); + expect(syncAggregatorAppsMock.mock.calls).toEqual([["conn-1", true], ["conn-1", true]]); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("groups provider variants and displays sign-in status without treating unseen apps as connected", async () => { + useComposioConnection(); + const observed = aggregatorApps([ + observedApp("notion", "Notion"), observedApp("notion_mcp", "Notion", { appSlug: "notion" }), + observedApp("slack", "Slack", { accounts: [{ id: "expired", alias: null, status: "EXPIRED", isDefault: false }] }), + observedApp("github", "GitHub", { status: "not_connected", accounts: [] }), + ]); + listAggregatorAppsMock.mockResolvedValue(observed); + syncAggregatorAppsMock.mockResolvedValue(observed); + await renderAppDetail(); + await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed.")); + const list = container.querySelector('ul[aria-label="Connected Composio apps"]')!; + expect(list.querySelectorAll("li")).toHaveLength(2); + expect(list.textContent).toContain("Notion2 accountsConnected"); + expect(list.textContent).toContain("SlackNeeds sign-in"); + expect(list.textContent).not.toContain("GitHub"); + }); + + it("keeps unavailable discovery explicit without automatically retrying unsupported gateways", async () => { + useComposioConnection(); + listAggregatorAppsMock.mockResolvedValue({ ...aggregatorApps(), discovery: { availability: "unsupported", message: "Account discovery unavailable." } }); + await renderAppDetail(); + await vi.waitFor(() => expect(container.textContent).toContain("Account discovery unavailable.")); + expect(syncAggregatorAppsMock).not.toHaveBeenCalled(); + expect(container.querySelector('button[aria-label="Refresh Composio"]')?.disabled).toBe(true); + }); + + it.each([ + ["UNVERIFIED", "Not verified"], + ["INITIATED", "Waiting for sign-in"], + ["EXPIRED", "Needs sign-in"], + ])("shows %s provider health as %s", async (status, label) => { + useComposioConnection(); + const observed = aggregatorApps([observedApp("notion", "Notion", { + accounts: [{ id: "account", alias: null, status, isDefault: false }], + })]); + listAggregatorAppsMock.mockResolvedValue(observed); + syncAggregatorAppsMock.mockResolvedValue(observed); + await renderAppDetail(); + await vi.waitFor(() => expect(container.textContent).toContain("Apps refreshed.")); + expect(container.querySelector('ul[aria-label="Connected Composio apps"]')?.textContent).toBe(`Notion${label}`); + }); + + it("does not request manager-only account inventory without configuration access", async () => { + useComposioConnection(); + listConnectionGrantsMock.mockResolvedValue({ grants: [], capabilities: fullCapabilities({ canConfigure: false }), currentUserId: "user-1", members: [] }); + await renderAppDetail(); + expect(listAggregatorAppsMock).not.toHaveBeenCalled(); + expect(syncAggregatorAppsMock).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain("Connected apps"); + }); + it.each([ { transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } }, { transport: "mcp_remote", config: { provider: "composio", parentConnectionId: "old-parent", toolkitSlug: "github" } }, @@ -914,6 +1040,48 @@ describe("AppDetail", () => { expect(putConnectionInstallsMock).not.toHaveBeenCalled(); }); + it("shows and removes task-granted access without changing the connection's default permissions", async () => { + const grant = { + id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1", + metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" }, + bindings: [{ targetType: "agent", targetId: "agent-1" }], + entries: [{ effect: "include", catalogEntryId: "catalog-write" }], + }; + listProfilesMock.mockResolvedValue({ profiles: [grant] }); + listPoliciesMock.mockResolvedValue({ policies: [{ + enabled: true, policyType: "require_approval", + selectors: { catalogEntryId: "catalog-write" }, + config: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" }, + }] }); + await renderAppDetail(); + const section = Array.from(container.querySelectorAll("section")).find(section => section.textContent?.includes("Additional agent access"))!; + expect(section.textContent).toContain("Write issue"); + expect(section.textContent).toContain("Ask first"); + listProfilesMock.mockResolvedValue({ profiles: [] }); + await act(async () => section.querySelector("button")!.click()); + await flushReact(); + expect(deleteProfileMock).toHaveBeenCalledWith("task-grant"); + expect(container.textContent).not.toContain("Additional agent access"); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(putConnectionInstallsMock).not.toHaveBeenCalled(); + }); + + it("keeps the task grant visible when removal fails", async () => { + listProfilesMock.mockResolvedValue({ profiles: [{ + id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1", + metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" }, + bindings: [{ targetType: "agent", targetId: "agent-1" }], + entries: [{ effect: "include", catalogEntryId: "catalog-read" }], + }] }); + deleteProfileMock.mockRejectedValue(new Error("Connection access changed. Reload and try again.")); + await renderAppDetail(); + const button = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Remove grant")!; + await act(async () => button.click()); + await flushReact(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Connection access changed"); + expect(button.disabled).toBe(false); + }); + it("persists agent access independently from always-installed agents", async () => { mockParams.tab = "permissions"; listProfilesMock.mockResolvedValue({ diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 77b8b3fd5d..f31cbd3eb0 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -52,6 +52,9 @@ import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip"; import { IdentitiesSection } from "./app-detail/IdentitiesSection"; import { PermissionsPanel } from "./app-detail/PermissionsPanel"; +import { AgentConnectionAccess } from "./app-detail/AgentConnectionAccess"; +import { ConnectedAggregatorApps } from "./app-detail/ConnectedAggregatorApps"; +import { isAppAggregator } from "@paperclipai/shared/aggregator-apps"; import { actionPermissionMutation } from "./app-detail/action-permissions"; import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; @@ -622,6 +625,8 @@ export function AppDetail({ renderActions, onReconnect }: { : permissionsLoading ? :
+ {isAppAggregator(brandKey) && grantsQuery.data?.capabilities.canConfigure === true + ? : null} {connection.config?.sourceTemplateKey === "browser-use-cloud" && } {connection.config?.sourceTemplateKey === "railway" && } {connection.config?.provider === "agentmail" && } @@ -657,19 +662,10 @@ export function AppDetail({ renderActions, onReconnect }: { onReplaceAudience={(grant, memberUserIds) => replaceAudience.mutate({ grantId: grant.id, memberUserIds })} /> - {isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) &&

Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.

} - {connection.authKind === "oauth" && ( -
-

- Provider permissions come from your last sign-in. Reconnect to grant missing write access, then enable the actions you need here. -

- {canReconnect && } -
- )} + {connection.config?.sourceTemplateKey === "composio" ?

+ These permissions apply to all apps available through this Composio connection. Manage app accounts and sign-in in Composio. +

: null} + {connection.config?.sourceTemplateKey !== "composio" && isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) &&

Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.

} apply(actionPermissionMutation(ids, next, enabledIds, askFirstIds))} onReviewQuarantined={reviewQuarantined} /> + { + await toolsApi.deleteProfile(profileId); + await profilesQuery.refetch(); + queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) }); + }} + /> {managesRemoteMcpAccess && isRemoteMcpConnectorId(connection.config?.sourceTemplateKey) && appDefinitionSlug(app) === name) ?? null; } - diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 2d306338dd..1c3512d8db 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -13,6 +13,8 @@ import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-so import { AppsConnect } from "./AppsConnect"; import { TooltipProvider } from "@/components/ui/tooltip"; +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "board-user", settled: true, failed: false }), resolveAccountUserId: async () => "board-user" })); + const listGalleryMock = vi.hoisted(() => vi.fn()); const experimentalMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { @@ -32,6 +34,8 @@ const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); +const openAggregatorTaskMock = vi.hoisted(() => vi.fn()); +vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: openAggregatorTaskMock }) })); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); const mockSearch = vi.hoisted(() => ({ value: "" })); const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined })); @@ -70,6 +74,7 @@ const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerd vi.mock("@/api/tools", () => ({ toolsApi: { + syncAggregatorApps: vi.fn().mockResolvedValue({ apps: [], sync: { status: "idle" }, discovery: { availability: "available" } }), listGallery: (companyId: string) => listGalleryMock(companyId), listApplications: (companyId: string) => listApplicationsMock(companyId), listConnections: (companyId: string) => listConnectionsMock(companyId), @@ -391,6 +396,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const connectLabel = `Connect ${provider[0].toUpperCase()}${provider.slice(1)}`; expect(container.textContent).toContain("available to the agent that asked for it"); expect(radioContaining("Any agent")).toBeUndefined(); + if (provider === "composio") { + expect(container.textContent).not.toContain("MCP server URL"); + expect(buttonByText("Reuse an existing session")?.getAttribute("aria-expanded")).toBe("false"); + expect(buttonByText(connectLabel)?.disabled).toBe(false); + await act(async () => buttonByText("Reuse an existing session")!.click()); + } expect(container.textContent).toContain("MCP server URL"); expect(container.textContent).not.toContain("Add your key"); expect(container.textContent).not.toContain("Step 1 of 2"); @@ -406,6 +417,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { // controls, and Radix unmounts collapsed content — so it has to be opened. expect(container.querySelector("select")).toBeNull(); await act(async () => buttonByText("Change")!.click()); + expect(buttonByText("Advanced")).toBeUndefined(); + expect(buttonByText("Change")?.getAttribute("aria-expanded")).toBe("true"); await act(async () => { const auth = container.querySelector("select")!; auth.value = "bearer"; @@ -433,6 +446,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); const root = await render(undefined, false, ); const connectLabel = `Connect ${provider[0].toUpperCase()}${provider.slice(1)}`; + if (provider === "composio") await act(async () => buttonByText("Reuse an existing session")!.click()); await act(async () => setInputValue(container.querySelector('input[type="password"]')!, "https://provider.example/mcp")); await act(async () => buttonByText(connectLabel)!.click()); await vi.waitFor(() => expect(startOAuthMock).toHaveBeenCalledWith(connection.id, { asCurrentUser: true, interactionId: "intent-inline" })); @@ -458,6 +472,61 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(popup.close).toHaveBeenCalled(); }); + it.each(["blocked", "throws", "closed"])("keeps inline Composio sign-in recoverable when its popup is %s", async (failure) => { + const popup = { closed: true, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; + const open = vi.spyOn(window, "open").mockImplementation(() => { + if (failure === "throws") throw new Error("Browser blocked the popup"); + return failure === "closed" ? popup as unknown as Window : null; + }); + const onPhaseChange = vi.fn(); + const onComplete = vi.fn(); + const connection = { id: "conn-composio", status: "draft", credentialPolicy: "per_user" }; + connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); + await render(undefined, false, ); + await act(async () => buttonByText("Connect Composio")!.click()); + await vi.waitFor(() => expect(container.textContent).toContain("Paperclip couldn’t open sign-in")); + + const link = container.querySelector('a[target="_blank"]')!; + expect(link.href).toBe("https://mcp.notion.com/authorize?state=resumed"); + expect(link.rel).toBe("noopener noreferrer"); + expect(onPhaseChange).toHaveBeenCalledWith("needs_retry"); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + await act(async () => link.dispatchEvent(click)); + expect(click.defaultPrevented).toBe(false); + expect(onPhaseChange).toHaveBeenLastCalledWith("authorizing"); + expect(container.textContent).not.toContain("Paperclip couldn’t open sign-in"); + expect(open).toHaveBeenCalledTimes(1); + expect(connectAppMock).toHaveBeenCalledTimes(1); + expect(startOAuthMock).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it.each(["arcade", "composio", "executor"])("keeps a native sign-in link when direct %s navigation is blocked", async (provider) => { + mockSearch.value = `source=${provider}`; + const connection = { id: "conn-provider", status: "draft", credentialPolicy: "shared" }; + connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [], auth: { kind: "oauth" } }); + navigateTopLevelMock.mockImplementationOnce(() => { throw new Error("Browser refused navigation"); }); + await render(); + if (provider !== "composio") { + await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://provider.example/mcp")); + } + await act(async () => buttonByText(`Connect ${provider[0].toUpperCase()}${provider.slice(1)}`)!.click()); + await vi.waitFor(() => expect(container.textContent).toContain("Paperclip couldn’t open sign-in")); + + const link = container.querySelector('a[target="_blank"]')!; + expect(link.textContent).toBe("open sign-in again"); + expect(link.href).toBe("https://mcp.notion.com/authorize?state=resumed"); + expect(link.rel).toBe("noopener noreferrer"); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + await act(async () => link.dispatchEvent(click)); + expect(click.defaultPrevented).toBe(false); + expect(navigateTopLevelMock).toHaveBeenCalledTimes(1); + expect(connectAppMock).toHaveBeenCalledTimes(1); + expect(startOAuthMock).toHaveBeenCalledTimes(1); + expect(finishAppMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Waiting for sign-in"); + }); + it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => { const onCancel = vi.fn(); const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } }; @@ -529,11 +598,83 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await render(); expect(container.textContent).not.toContain("Enable MCP aggregators"); await passAccessStep(); - expect(container.textContent).toContain("MCP server URL"); + expect(container.textContent?.includes("MCP server URL")).toBe(provider !== "composio"); + expect(buttonByText("Advanced")).toBeUndefined(); + expect(buttonByText("Change")?.getAttribute("aria-expanded")).toBe("false"); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); + it("keeps an existing Composio session URL when its disclosure is closed", async () => { + mockSearch.value = "source=composio&new=1"; + await render(); + expect(container.querySelector('input[id$="-url"]')).toBeNull(); + await act(async () => buttonByText("Reuse an existing session")!.click()); + await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://session.example/mcp")); + await act(async () => buttonByText("Reuse an existing session")!.click()); + expect(container.querySelector('input[id$="-url"]')).toBeNull(); + await act(async () => buttonByText("Connect Composio")!.click()); + await vi.waitFor(() => expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ link: "https://session.example/mcp" }))); + }); + + it("reuses saved Composio accounts for a targeted app setup URL", async () => { + mockSearch.value = "source=composio&targetToolkit=circleback_mcp"; + listConnectionsMock.mockResolvedValue({ connections: [{ id: "saved-composio", status: "active", enabled: true, transport: "mcp_remote", config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }] }); + await render(); + await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps?source=composio&targetToolkit=circleback_mcp", { replace: true })); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("retains explicit new-account setup even when Composio is saved", async () => { + mockSearch.value = "source=composio&targetToolkit=circleback_mcp&new=1"; + listConnectionsMock.mockResolvedValue({ connections: [{ id: "saved-composio", status: "active", enabled: true, transport: "mcp_remote", config: { sourceTemplateKey: "composio", url: "https://connect.composio.dev/mcp" } }] }); + await render(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Connect Circleback through Composio"); + }); + + it.each(["arcade", "composio"])("retains the selected upstream app in %s setup without claiming app authorization", async (provider) => { + mockSearch.value = `source=${provider}&targetToolkit=hubspot`; + await render(); + expect(container.textContent).toContain(`Connect HubSpot through ${provider === "arcade" ? "Arcade" : "Composio"}`); + expect(container.textContent).toContain(provider === "composio" ? "sign in to the app in Composio" : "Manage app sign-in in the provider"); + expect(connectAppMock).not.toHaveBeenCalled(); + }); + + it("returns completed Arcade gateway setup to Apps without creating an agent task", async () => { + mockSearch.value = "source=arcade&targetToolkit=hubspot"; + connectAppMock.mockResolvedValue({ connectionId: "new-arcade", connection: { id: "new-arcade", status: "active", credentialPolicy: "shared" }, catalog: [] }); + await render(); + await passAccessStep(); + await act(async () => setInputValue(container.querySelector('input[id$="-url"]')!, "https://api.arcade.dev/mcp/test")); + await act(async () => buttonByText("Connect Arcade")!.click()); + await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps")); + expect(openAggregatorTaskMock).not.toHaveBeenCalled(); + }); + + it.each(["source=composio", "source=composio&targetToolkit=hubspot"])("opens Composio permissions after gateway setup for %s", async (search) => { + mockSearch.value = search; + connectAppMock.mockResolvedValue({ connectionId: "new-composio", connection: { id: "new-composio", status: "active", credentialPolicy: "shared" }, catalog: [] }); + await render(); + await act(async () => buttonByText("Connect Composio")!.click()); + await vi.waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/apps/new-composio/permissions")); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ grantKind: "organization" })); + expect(putConnectionInstallsMock).toHaveBeenCalledWith("new-composio", [{ targetType: "company", targetId: "company-1" }]); + expect(finishAppMock).toHaveBeenCalledWith("company-1", "new-composio", expect.objectContaining({ access: "all_agents" })); + expect(openAggregatorTaskMock).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem("paperclip:mcp-upstream-app:company-1:new-composio")).toBeNull(); + }); + + it("recovers upstream app context after a gateway OAuth redirect", async () => { + window.sessionStorage.setItem("paperclip:mcp-upstream-app:company-1:composio-draft", "hubspot"); + mockSearch.value = "source=composio&resume=composio-draft&oauth=connected"; + getConnectionMock.mockResolvedValue({ id: "composio-draft", status: "draft", credentialPolicy: "per_user", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" } }); + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("Connect HubSpot through Composio")); + expect(openAggregatorTaskMock).not.toHaveBeenCalled(); + }); + it.each(["arcade", "composio", "executor"])("explains a failed %s OAuth return and retries the same saved draft", async (provider) => { const draft = { id: "conn-oauth-draft", companyId: "company-1", status: "draft", authKind: "oauth", credentialPolicy: "shared", config: { sourceTemplateKey: provider, connectionMethodKey: "mcp", url: "https://example.com/mcp" } }; mockSearch.value = `source=${provider}&resume=${draft.id}&oauth=failed&code=oauth_callback_failed&error_description=untrusted-provider-message`; @@ -541,6 +682,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } }); await render(); await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Authorization did not complete")); + expect(container.querySelector('input[id$="-url"]')?.value).toBe("https://example.com/mcp"); expect(container.textContent).not.toContain("untrusted-provider-message"); expect(buttonByText("Try again")).toBeTruthy(); await act(async () => buttonByText("Try again")!.click()); @@ -1909,7 +2051,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("Vercel Connect"); const keyInput = container.querySelector('input[type="password"]'); - const advanced = buttonByText("Advanced"); + const advanced = buttonByText("Change"); expect(keyInput).toBeTruthy(); // PAP-659: the optional controls share the one Advanced disclosure with // the access defaults, so they are visible because it is already open — @@ -2000,7 +2142,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(JSON.stringify(connectAppMock.mock.calls[0])).not.toContain("credentialValues"); }); - it("folds optional customer-owned OAuth details under Advanced", async () => { + it("folds optional customer-owned OAuth details under Change", async () => { mockParams.appKey = "posthog"; listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] }); await render(); @@ -2011,7 +2153,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); - const advanced = buttonByText("Advanced"); + const advanced = buttonByText("Change"); expect(advanced?.getAttribute("aria-expanded")).toBe("false"); expect(container.textContent).not.toContain("Use your own OAuth app"); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 5e48ac7285..69973b86bf 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -1,3 +1,10 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { isRetiredComposioConnection } from "@paperclipai/shared"; +import { findComposioCatalogApp } from "@paperclipai/shared/aggregator-app-catalog"; +import { toolsApi } from "@/api/tools"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; import type { ToolConnectionCredentialSource } from "@paperclipai/shared"; import { useCompany } from "@/context/CompanyContext"; @@ -16,6 +23,23 @@ export function AppsConnect({ byoOnly = false, credentialSource = "paperclip_vau const [searchParams] = useSearchParams(); const { appKey } = useParams<{ appKey?: string }>(); const source = searchParams.get("source") ?? appKey ?? searchParams.get("appKey"); + const toolkit = searchParams.get("targetToolkit"); + const reuseComposio = source === "composio" && Boolean(toolkit && findComposioCatalogApp(toolkit)) + && searchParams.get("new") !== "1" && !searchParams.get("resume") && !searchParams.get("reconnect") + && !byoOnly && credentialSource === "paperclip_vault"; + const saved = useQuery({ queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listConnections(selectedCompanyId!), enabled: reuseComposio && Boolean(selectedCompanyId), retry: false }); + const hasSavedComposio = reuseComposio && saved.data?.connections.some(connection => + connection.config?.sourceTemplateKey === "composio" && connection.transport === "mcp_remote" + && connection.status === "active" && connection.enabled && !isRetiredComposioConnection(connection)); + useEffect(() => { + if (hasSavedComposio) navigate(`/apps?source=composio&targetToolkit=${encodeURIComponent(toolkit!)}`, { replace: true }); + }, [hasSavedComposio, toolkit, navigate]); + if (reuseComposio && selectedCompanyId && (saved.isPending || hasSavedComposio)) return

Checking saved Composio accounts…

; + if (reuseComposio && saved.isError) return
+

Couldn’t load your Composio accounts.

+
+
; const returningToSkills = source === "github" && selectedCompanyId && skillSourceReturnPath(selectedCompanyId); function returnToSkills() { const path = selectedCompanyId && consumeSkillSourceReturn(selectedCompanyId); diff --git a/ui/src/pages/apps/ArcadeDiscoverySetup.tsx b/ui/src/pages/apps/ArcadeDiscoverySetup.tsx new file mode 100644 index 0000000000..9914bc77cc --- /dev/null +++ b/ui/src/pages/apps/ArcadeDiscoverySetup.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toolsApi } from "@/api/tools"; +import { useAccountIdentity } from "@/api/companies-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import type { ToolConnection } from "@paperclipai/shared"; + +export function ArcadeDiscoverySetup({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) { + const [apiKey, setApiKey] = useState(""); + const [userId, setUserId] = useState(""); + const queries = useQueryClient(); + const { userId: viewingUserId, settled } = useAccountIdentity(); + const save = useMutation({ mutationFn: async () => { + const result = await toolsApi.configureArcadeDiscovery(connection.id, { apiKey: apiKey.trim(), userId: userId.trim() }); + queries.setQueryData(queryKeys.tools.aggregatorApps(connection.id, viewingUserId), result); + }, + onSuccess: onClose, + }); + return { if (!open && !save.isPending) onClose(); }}> + Sync Arcade accounts + See apps available through “{connection.name}”. Your gateway works without account sync. + +
{ event.preventDefault(); save.mutate(); }}> +
+

Create a key in Arcade for the same project as this gateway. The key is stored securely and used only for account sync.

+ setApiKey(event.target.value)} /> +
+
+

Use the end-user ID configured for this gateway’s sign-in. This may differ from your email address.

+ setUserId(event.target.value)} /> +
+ {save.isError ?

{save.error instanceof Error ? save.error.message : "Couldn’t save account sync. Try again."}

: null} + + + +
+
; +} diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 35ab90ed2f..cdd58286e3 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -2,11 +2,38 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Browse } from "./Browse"; import { getAppStoreDefinition } from "@paperclipai/shared"; import { queryKeys } from "@/lib/queryKeys"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; + +const aggregatorCatalogMock = vi.hoisted(() => [] as AggregatorAppCatalogEntry[]); +const openNewIssueMock = vi.hoisted(() => vi.fn()); +const listAgentsMock = vi.hoisted(() => vi.fn()); +const setupComposioAppMock = vi.hoisted(() => vi.fn()); +const listComposioAppsMock = vi.hoisted(() => vi.fn()); +const syncComposioAppsMock = vi.hoisted(() => vi.fn()); +const refreshComposioAppsMock = vi.hoisted(() => vi.fn()); +const manageComposioAppAccountMock = vi.hoisted(() => vi.fn()); +function genericResponse(value: { apps?: any[]; sync?: Record }) { + return { provider: "composio", discovery: { availability: "available", message: null }, + sync: { status: "ready", ...value.sync }, apps: (value.apps ?? []).map(snapshot => ({ ...snapshot, provider: snapshot.provider ?? "composio", + appSlug: snapshot.appSlug ?? aggregatorCatalogMock.find(app => app.aliases.includes(snapshot.toolkit) || app.routes.some(route => route.toolkit === snapshot.toolkit))?.slug ?? snapshot.toolkit, + appName: snapshot.appName ?? snapshot.toolkit })) }; +} +vi.mock("@/api/agents", () => ({ agentsApi: { list: (companyId: string) => listAgentsMock(companyId) } })); +vi.mock("@paperclipai/shared/aggregator-app-catalog", async (importOriginal) => ({ + ...await importOriginal(), + AGGREGATOR_APP_CATALOG: aggregatorCatalogMock, + findComposioCatalogApp: (toolkit: string) => aggregatorCatalogMock.find(app => app.aliases.includes(toolkit) || app.routes.some(route => route.provider === "composio" && route.toolkit === toolkit)), +})); +vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: openNewIssueMock }) })); + +const accountIdentity = vi.hoisted(() => ({ userId: "board-user" as string | null, settled: true, failed: false })); +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => accountIdentity })); const listGalleryMock = vi.hoisted(() => vi.fn()); const listApplicationsMock = vi.hoisted(() => vi.fn()); @@ -26,6 +53,16 @@ vi.mock("@/api/email", () => ({ emailApi: { control: emailControlMock } })); vi.mock("@/api/tools", () => ({ toolsApi: { + listAggregatorApps: async (...args: unknown[]) => genericResponse(await listComposioAppsMock(...args)), + syncAggregatorApps: async (...args: unknown[]) => genericResponse(await syncComposioAppsMock(...args)), + refreshAggregatorApps: async (...args: unknown[]) => genericResponse(await refreshComposioAppsMock(...args)), + refreshCatalog: vi.fn().mockResolvedValue({}), + configureArcadeDiscovery: vi.fn(), + syncComposioApps: (...args: unknown[]) => syncComposioAppsMock(...args), + listComposioApps: (...args: unknown[]) => listComposioAppsMock(...args), + refreshComposioApps: (...args: unknown[]) => refreshComposioAppsMock(...args), + manageComposioAppAccount: (...args: unknown[]) => manageComposioAppAccountMock(...args), + setupComposioApp: (...args: unknown[]) => setupComposioAppMock(...args), listGallery: (companyId: string) => listGalleryMock(companyId), listApplications: (companyId: string) => listApplicationsMock(companyId), listConnections: (companyId: string) => listConnectionsMock(companyId), @@ -117,6 +154,7 @@ function connection(overrides: Record = {}) { return { id: "conn-notion", applicationId: "app-notion", + transport: "mcp_remote", name: "devinfoley@gmail.com", status: "active", enabled: true, @@ -136,6 +174,13 @@ describe("Connectors landing page", () => { let root: ReturnType; beforeEach(() => { + accountIdentity.userId = "board-user"; accountIdentity.settled = true; + syncComposioAppsMock.mockReset().mockImplementation((...args) => listComposioAppsMock(...args)); + listComposioAppsMock.mockReset().mockResolvedValue({ apps: [] }); + refreshComposioAppsMock.mockReset().mockResolvedValue({ apps: [] }); + manageComposioAppAccountMock.mockReset(); + listAgentsMock.mockResolvedValue([{ id: "default-agent", name: "Default agent", role: "ceo", reportsTo: null, status: "active", createdAt: new Date(0) }]); + aggregatorCatalogMock.splice(0); experimentalMock.mockResolvedValue({ enableChatConnectors: true }); chatListMock.mockResolvedValue([]); chatSetupMock.mockReset().mockResolvedValue({ status: "archived" }); @@ -175,10 +220,11 @@ describe("Connectors landing page", () => { act(() => root?.unmount()); container.remove(); document.body.innerHTML = ""; + window.history.replaceState({}, "", "/"); vi.clearAllMocks(); }); - async function renderBrowse() { + async function renderBrowse(allCatalog = true) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -186,14 +232,466 @@ describe("Connectors landing page", () => { await act(async () => { root.render( - + , ); }); await flushReact(); + if (allCatalog) { + await clickButton("All", container); + } return client; } + function indexedApp(name: string, providers: ("composio" | "arcade" | "executor")[] = ["composio"]): AggregatorAppCatalogEntry { + const slug = name.toLowerCase().replaceAll(" ", "-"); + return { name, slug, aliases: [slug], routes: providers.map((provider) => ({ + provider, toolkit: slug, logoUrl: `https://logos.example.com/${slug}.svg`, docsUrl: `https://docs.${provider}.dev/${slug}`, + })) }; + } + + async function search(value: string) { + const input = container.querySelector('input[aria-label="Search connectors"]')!; + await act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + } + + function composioFixture() { + aggregatorCatalogMock.push(indexedApp("Circleback")); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } })] }); + const snapshot = { connectionId: "conn-notion", toolkit: "circleback", status: "connected", checkedAt: new Date().toISOString(), accounts: [{ id: "ca-work", alias: "Meeting notes", status: "ACTIVE", isDefault: true }] }; + listComposioAppsMock.mockResolvedValue({ apps: [snapshot] }); + refreshComposioAppsMock.mockResolvedValue({ apps: [snapshot] }); + return snapshot; + } + + async function clickButton(text: string, scope: ParentNode = document) { + const button = Array.from(scope.querySelectorAll("button")).find(button => button.textContent?.trim() === text)!; + expect(button).toBeTruthy(); + await act(() => button.click()); + await flushReact(); + } + + async function accountMenu(action: string, name = "Meeting notes") { + const trigger = document.querySelector(`button[aria-label="Manage ${name}"]`)!; + await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + const item = Array.from(document.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === action)!; + await act(() => item.click()); + await flushReact(); + } + + async function connectionMenu(name = "Meeting notes") { + const trigger = container.querySelector(`[data-app-slug="circleback"] button[aria-label="Manage ${name} connection"]`)!; + expect(trigger).toBeTruthy(); + await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + return Array.from(document.querySelectorAll('[role="menuitem"]')); + } + + async function openComposioAccountMenu(name = "Composio account") { + const trigger = container.querySelector(`[data-app-slug="composio"] button[aria-label="Manage ${name} connection"]`)!; + expect(trigger).toBeTruthy(); + await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + return document.querySelector('[role="menu"]')!; + } + + it("defaults to Paperclip with five chips, expands search to All, and retains explicit source scope", async () => { + aggregatorCatalogMock.push(indexedApp("Remote App", ["composio"])); + await renderBrowse(false); + expect(container.querySelector('[aria-label="App filters"]')?.textContent).toBe("PaperclipComposioArcadeInstalledAll"); + expect(container.querySelector('[data-app-slug="remote-app"]')).toBeNull(); + await search("Remote App"); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("All"); + expect(container.querySelector('[data-app-slug="remote-app"]')).toBeTruthy(); + await search(""); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Paperclip"); + await clickButton("Arcade", container); + await search("Remote App"); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Arcade"); + expect(container.textContent).toContain("No connectors match"); + await clickButton("Clear search", container); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe("Arcade"); + }); + + it.each(["composio", "arcade"] as const)("scopes %s to its catalog and accounts without native catalog overlaps", async (provider) => { + const otherProvider = provider === "composio" ? "arcade" : "composio"; + const providerName = provider === "composio" ? "Composio" : "Arcade"; + aggregatorCatalogMock.push(indexedApp("Notion", [provider]), indexedApp("Remote App", [provider]), indexedApp("Other App", [otherProvider])); + listGalleryMock.mockResolvedValue({ apps: ["notion", provider, otherProvider].map(getAppStoreDefinition) }); + listApplicationsMock.mockResolvedValue({ applications: [application(), application({ id: "gateway-app", name: providerName, metadata: { sourceTemplateKey: provider } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Native Notion" }), connection({ id: "gateway", applicationId: "gateway-app", name: `${providerName} account`, config: { sourceTemplateKey: provider } })] }); + listComposioAppsMock.mockResolvedValue({ apps: [{ provider, appSlug: "notion", appName: "Notion", connectionId: "gateway", toolkit: "notion", status: "not_connected", accounts: [] }] }); + await renderBrowse(false); + expect(container.querySelector('[data-app-slug="notion"]')).toBeTruthy(); + + await clickButton(providerName, container); + expect(container.querySelector(`[data-app-slug="${provider}"]`)).toBeTruthy(); + expect(container.querySelector('[data-app-slug="remote-app"]')).toBeTruthy(); + expect(container.querySelector('[data-app-slug="notion"]')).toBeNull(); + expect(container.querySelector(`[data-app-slug="${otherProvider}"]`)).toBeNull(); + expect(container.querySelector('[data-app-slug="other-app"]')).toBeNull(); + + await search("Notion"); + expect(container.querySelector('[aria-pressed="true"]')?.textContent).toBe(providerName); + expect(container.textContent).toContain("No connectors match"); + await clickButton("All", container); + expect(container.querySelector('[data-app-slug="notion"]')?.textContent).toContain("Native Notion"); + }); + + it("does not reuse another viewing user's managed-account cache", async () => { + composioFixture(); + const client = await renderBrowse(false); + expect(container.textContent).toContain("Meeting notes"); + listComposioAppsMock.mockResolvedValue({ apps: [] }); + accountIdentity.userId = "another-user"; + await act(async () => root.render()); + await flushReact(); + expect(container.textContent).not.toContain("Meeting notes"); + expect(client.getQueryData(queryKeys.tools.aggregatorApps("conn-notion", "board-user"))).toBeTruthy(); + expect(client.getQueryData<{ apps: unknown[] }>(queryKeys.tools.aggregatorApps("conn-notion", "another-user"))?.apps).toEqual([]); + }); + + it("groups native and matching-label accounts across providers and gateways without dropping any lines", async () => { + aggregatorCatalogMock.push(indexedApp("Notion", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: ["notion", "composio", "arcade", "executor"].map(getAppStoreDefinition) }); + const gateways = ["composio", "arcade", "executor", "arcade"]; + listApplicationsMock.mockResolvedValue({ applications: [application(), ...gateways.map((provider, index) => application({ id: `app-${index}`, name: provider, metadata: { sourceTemplateKey: provider } }))] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Native" }), ...gateways.map((provider, index) => connection({ id: `gateway-${index}`, applicationId: `app-${index}`, name: `Gateway ${index}`, config: { sourceTemplateKey: provider } }))] }); + listComposioAppsMock.mockImplementation((id: string) => ({ apps: [{ provider: gateways[Number(id.split("-")[1])], appSlug: "notion", appName: "Notion", connectionId: id, toolkit: "notion", checkedAt: new Date().toISOString(), status: "connected", accounts: [{ id: "same-id", alias: "Work", status: "ACTIVE", isDefault: false }] }] })); + await renderBrowse(false); + expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1); + const row = container.querySelector('[data-app-slug="notion"]')!; + expect(row.querySelectorAll('button[aria-label="Manage Work connection"]')).toHaveLength(4); + expect(row.textContent).toContain("Native"); + for (const provider of ["Composio", "Arcade", "Executor"]) expect(row.textContent).toContain(`Managed by ${provider}`); + await clickButton("Arcade", container); + expect(container.querySelectorAll('[data-app-slug="notion"] button[aria-label="Manage Work connection"]')).toHaveLength(4); + expect(container.querySelector('[data-app-slug="notion"] button[aria-label="Connect Notion"]')).toBeNull(); + }); + + it("offers only provider management in imported account menus", async () => { + composioFixture(); + await renderBrowse(); + const items = await connectionMenu(); + expect(items.map(item => item.textContent?.trim())).toEqual(["Open in Composio"]); + expect(items[0].getAttribute("href")).toBe("https://dashboard.composio.dev/~/org/connect/apps"); + expect(items[0].getAttribute("target")).toBe("_blank"); + expect(navigateMock).not.toHaveBeenCalled(); + expect(manageComposioAppAccountMock).not.toHaveBeenCalled(); + expect(archiveConnectionMock).not.toHaveBeenCalled(); + }); + + it("does not expose local deletion or renaming for imported accounts", async () => { + composioFixture(); + await renderBrowse(); + const trigger = container.querySelector('button[aria-label="Manage Meeting notes connection"]')!; + await act(() => { trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); }); + await flushReact(); + expect(document.querySelector('[role="menu"]')?.textContent).toContain("Open in Composio"); + expect(document.querySelector('[role="menu"]')?.textContent).not.toContain("Remove connection"); + expect(document.querySelector('[role="menu"]')?.textContent).not.toContain("Rename"); + expect(manageComposioAppAccountMock).not.toHaveBeenCalled(); + }); + + it("shows verified upstream accounts as installed on every page, searches their labels, and opens Manage", async () => { + composioFixture(); + listUserDirectoryMock.mockResolvedValue({ users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: null } }] }); + aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${index}`))); + await renderBrowse(); + const circleback = container.querySelector('[data-app-slug="circleback"]')!; + expect(circleback.getAttribute("data-connected")).toBe("true"); + expect(circleback.textContent).toContain("Accounts managed in Composio."); + expect(circleback.textContent).toContain("Managed by Composio ·“Composio account”"); + expect(circleback.textContent).not.toContain("Connected by"); + expect(circleback.textContent).not.toContain("Dotta"); + expect(circleback.querySelector('[title="Connected"]')).toBeTruthy(); + expect(circleback.querySelector('button[aria-label="Manage Meeting notes"]')).toBeTruthy(); + expect(circleback.querySelector('button[aria-label="Connect Circleback"]')).toBeNull(); + await clickButton("Next", container); + expect(container.querySelector('[data-app-slug="circleback"] button[aria-label="Manage Circleback"]')).toBeTruthy(); + await search("Meeting notes"); + expect(container.querySelectorAll('[data-app-slug="circleback"]')).toHaveLength(1); + await clickButton("Manage", container); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Meeting notes"); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('“Composio account”'); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Accounts and sign-in are managed in Composio."); + expect(document.querySelectorAll('[role="dialog"] a[href="/apps/conn-notion/permissions"]')).toHaveLength(1); + expect(openNewIssueMock).not.toHaveBeenCalled(); + }); + + it("uses the waiting status for an aggregator account until sign-in is verified", async () => { + const snapshot = composioFixture(); + const pending = { ...snapshot, status: "not_connected", accounts: [{ ...snapshot.accounts[0], status: "INITIATED" }] }; + listComposioAppsMock.mockResolvedValue({ apps: [pending] }); + refreshComposioAppsMock.mockResolvedValue({ apps: [pending] }); + await renderBrowse(); + const circleback = container.querySelector('[data-app-slug="circleback"]')!; + expect(circleback.textContent).toContain("Waiting for sign-in"); + expect(circleback.textContent).toContain("Finish connecting this account."); + expect(circleback.querySelector('[title="Connected"]')).toBeNull(); + await act(() => circleback.querySelector('button[aria-label="Manage Meeting notes"]')!.click()); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Manage Circleback"); + }); + + it("discovers app authorization on refresh and reflects a disconnection when returning from Composio", async () => { + composioFixture(); + const response = await listComposioAppsMock(); + listComposioAppsMock.mockResolvedValue({ apps: [] }); + syncComposioAppsMock.mockResolvedValue(response); + await renderBrowse(); + await vi.waitFor(() => expect(container.querySelector('[data-app-slug="circleback"]')?.getAttribute("data-connected")).toBe("true")); + listComposioAppsMock.mockResolvedValue({ apps: [] }); + syncComposioAppsMock.mockResolvedValue({ apps: [] }); + await act(() => focusManager.setFocused(false)); + await act(() => focusManager.setFocused(true)); + await vi.waitFor(() => expect(container.querySelector('button[aria-label="Connect Circleback"]')).toBeTruthy()); + focusManager.setFocused(undefined); + expect(setupComposioAppMock).not.toHaveBeenCalled(); + }); + + it("opens provider-owned management and refreshes observed accounts without mutation", async () => { + const snapshot = composioFixture(); + await renderBrowse(); + await clickButton("Manage", container); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Accounts and sign-in are managed in Composio."); + expect(dialog.querySelector('a[href="https://dashboard.composio.dev/~/org/connect/apps"]')?.getAttribute("target")).toBe("_blank"); + expect(dialog.textContent).not.toContain("Rename"); + expect(dialog.textContent).not.toContain("Disconnect"); + const disconnected = { ...snapshot, status: "not_connected", accounts: [] }; + refreshComposioAppsMock.mockResolvedValue({ apps: [disconnected] }); + await clickButton("Refresh", dialog); + expect(refreshComposioAppsMock).toHaveBeenCalledWith("conn-notion", ["circleback"]); + expect(dialog.textContent).toContain("No connected Circleback accounts."); + expect(manageComposioAppAccountMock).not.toHaveBeenCalled(); + expect(setupComposioAppMock).not.toHaveBeenCalled(); + }); + + it("preserves failed observations with an unverified status and offers retry", async () => { + const snapshot = composioFixture(); + listComposioAppsMock.mockResolvedValue({ apps: [{ ...snapshot, errorAt: new Date().toISOString() }], sync: { status: "error", error: "Unavailable" } }); + await renderBrowse(); + expect(container.querySelector('button[aria-label="Manage Circleback"]')).toBeTruthy(); + expect(container.querySelector('[data-app-slug="circleback"] [title="Connected"]')).toBeNull(); + expect(container.textContent).toContain("Last known account · Refresh to verify"); + expect(container.textContent).toContain("Unavailable"); + const menu = await openComposioAccountMenu(); + await act(() => Array.from(menu.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")!.click()); + await flushReact(); + expect(syncComposioAppsMock).toHaveBeenCalledWith("conn-notion", true); + }); + + it("refreshes only the selected Composio account while another account is syncing", async () => { + composioFixture(); + const work = connection({ id: "conn-work", applicationId: "gateway-app", name: "Work Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }); + const personal = connection({ id: "conn-personal", applicationId: "gateway-app", name: "Personal Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }); + listConnectionsMock.mockResolvedValue({ connections: [work, personal] }); + listComposioAppsMock.mockImplementation((id: string) => Promise.resolve({ apps: [], sync: { status: id === "conn-work" ? "syncing" : "ready" } })); + await renderBrowse(); + expect(container.textContent).not.toContain("Refresh Composio"); + let menu = await openComposioAccountMenu("Work Composio"); + expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")?.getAttribute("aria-disabled")).toBe("true"); + await act(() => { menu.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); + await flushReact(); + menu = await openComposioAccountMenu("Personal Composio"); + const refresh = Array.from(menu.querySelectorAll('[role="menuitem"]')).find(item => item.textContent?.trim() === "Refresh Composio")!; + expect(refresh.getAttribute("aria-disabled")).not.toBe("true"); + syncComposioAppsMock.mockClear(); + await act(() => refresh.click()); + await flushReact(); + expect(syncComposioAppsMock.mock.calls.filter(([, force]) => force === true)).toEqual([["conn-personal", true]]); + }); + + it("discovers native-overlap accounts without adding an aggregator connect offer", async () => { + const snapshot = composioFixture(); + aggregatorCatalogMock.push(indexedApp("Notion")); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio"), getAppStoreDefinition("notion")] }); + listComposioAppsMock.mockResolvedValue({ apps: [{ ...snapshot, toolkit: "notion", accounts: [{ ...snapshot.accounts[0], alias: "Imported workspace" }] }] }); + await renderBrowse(); + const row = container.querySelector('[data-app-slug="notion"]')!; + expect(row.textContent).toContain("Imported workspace"); + expect(row.textContent).toContain("Managed by Composio ·“Composio account”"); + expect(row.querySelector('button[aria-label*="third-party"]')).toBeNull(); + expect(row.querySelector('button[aria-label="Connect Notion"]')).toBeTruthy(); + expect(syncComposioAppsMock).toHaveBeenCalledWith("conn-notion"); + expect(refreshComposioAppsMock).not.toHaveBeenCalled(); + }); + + it("suppresses aggregator routes for native apps, including hidden native connectors", async () => { + aggregatorCatalogMock.push(indexedApp("Notion", ["composio", "arcade"]), indexedApp("Google Sheets"), indexedApp("GitHub API"), indexedApp("Context7"), indexedApp("HubSpot", ["composio", "arcade"])); + await renderBrowse(); + expect(container.querySelectorAll('[data-app-slug="notion"]')).toHaveLength(1); + expect(container.querySelector('[data-app-slug="notion"] button[aria-label*="third-party"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="google-sheets"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="github-api"]')).toBeNull(); + expect(container.querySelector('[data-app-slug="context7"]')).toBeNull(); + expect(container.querySelectorAll('[data-app-slug="hubspot"]')).toHaveLength(1); + expect(container.querySelectorAll('[data-app-slug="hubspot"] button[aria-label*="third-party"]')).toHaveLength(2); + }); + + it("paginates the catalog while keeping installed connectors above every page and resets on search", async () => { + aggregatorCatalogMock.push(...Array.from({ length: 60 }, (_, index) => indexedApp(`Indexed App ${String(index).padStart(2, "0")}`))); + listApplicationsMock.mockResolvedValue({ applications: [application()] }); + listConnectionsMock.mockResolvedValue({ connections: [connection()] }); + await renderBrowse(); + expect(container.querySelectorAll('[data-connected="false"][data-app-slug]:not([data-app-slug="custom-mcp"])')).toHaveLength(50); + expect(container.querySelector('[aria-label="Connector list"] > [data-app-slug]')?.getAttribute("data-app-slug")).toBe("notion"); + const next = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "Next")!; + await act(() => next.click()); + expect(container.textContent).toContain("Page 2 of"); + expect(container.querySelector('[aria-label="Connector list"] > [data-app-slug]')?.getAttribute("data-app-slug")).toBe("notion"); + await search("Indexed App 59"); + expect(container.textContent).toContain("Page 1 of 1"); + expect(container.querySelector('[data-app-slug="indexed-app-59"]')).not.toBeNull(); + await search("no-such-app"); + expect(container.textContent).toContain("No connectors match"); + expect(container.querySelector('[aria-label="Connector catalog pages"]')).toBeNull(); + }); + + it("offers each provider once and carries the exact selected toolkit to setup", async () => { + aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"])); + await renderBrowse(); + await act(() => container.querySelector('button[aria-label="Connect HubSpot"]')!.click()); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Which service would you like to use?"); + await act(() => Array.from(dialog.querySelectorAll("button")).find((button) => button.textContent?.includes("Arcade"))!.click()); + expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=arcade&targetToolkit=hubspot"); + }); + + it.each(["composio", "arcade"] as const)("skips provider selection and opens setup for a sole %s provider", async (provider) => { + const app = indexedApp("Circleback", [provider]); + app.routes[0].toolkit = "circle_back"; + aggregatorCatalogMock.push(app); + await renderBrowse(); + await act(() => container.querySelector('button[aria-label="Connect Circleback"]')!.click()); + expect(navigateMock).toHaveBeenCalledWith(`/apps/connect?source=${provider}&targetToolkit=circle_back`); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("opens the saved gateway flow directly for a sole provider and allows cancellation", async () => { + aggregatorCatalogMock.push(indexedApp("Circleback")); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] }); + await renderBrowse(); + await act(() => container.querySelector('button[aria-label="Connect Circleback"]')!.click()); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Connect Circleback through Composio"); + expect(dialog.textContent).not.toContain("Which service would you like to use?"); + expect(dialog.textContent).not.toContain("Back"); + expect(dialog.textContent).not.toContain("Connection settings"); + expect(dialog.textContent).not.toContain("An agent can check Circleback in Composio"); + expect(dialog.querySelector('[data-slot="badge"]')).toBeNull(); + expect(dialog.querySelector('#composio-app-account')?.value).toBe("conn-notion"); + expect(dialog.querySelector('#composio-app-agent')).toBeNull(); + expect(dialog.textContent).not.toContain("Which agent"); + expect(dialog.textContent).not.toContain("Using"); + expect(dialog.textContent).not.toContain("Use another account"); + expect(dialog.querySelector('a[href="https://dashboard.composio.dev/~/org/connect/apps"]')).toBeNull(); + expect(listAgentsMock).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalled(); + await act(() => Array.from(dialog.querySelectorAll("button")).find((button) => button.textContent === "Cancel")!.click()); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("configures an app directly through a saved gateway without creating an agent task", async () => { + setupComposioAppMock.mockResolvedValueOnce({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/test" }) + .mockRejectedValueOnce(new Error("Finish connecting this app in Composio, then check again")).mockResolvedValueOnce({ status: "connected" }); + aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"])); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] }); + await renderBrowse(); + expect(container.querySelector('[data-app-slug="hubspot"]')?.getAttribute("data-connected")).toBe("false"); + await act(() => container.querySelector('button[aria-label="Connect HubSpot"]')!.click()); + await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent?.includes("Composio"))!.click()); + expect(document.querySelector('#composio-app-account')?.value).toBe("conn-notion"); + expect(navigateMock).not.toHaveBeenCalled(); + await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "Continue")!.click()); + await vi.waitFor(() => expect(setupComposioAppMock).toHaveBeenCalledWith("conn-notion", "hubspot", { action: "start" })); + await vi.waitFor(() => expect(document.querySelector('a[href="https://connect.composio.dev/link/test"]')).not.toBeNull()); + expect(openNewIssueMock).not.toHaveBeenCalled(); + await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "I’ve connected it")!.click()); + await vi.waitFor(() => expect(setupComposioAppMock).toHaveBeenCalledWith("conn-notion", "hubspot", { action: "complete" })); + await vi.waitFor(() => expect(document.querySelector('[role="alert"]')?.textContent).toContain("Finish connecting")); + expect(document.querySelector('a[href="https://connect.composio.dev/link/test"]')).not.toBeNull(); + await act(() => Array.from(document.querySelector('[role="dialog"]')!.querySelectorAll("button")).find((button) => button.textContent === "I’ve connected it")!.click()); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + expect(pushToastMock).toHaveBeenCalledWith({ title: "HubSpot is connected through Composio.", tone: "success" }); + expect(openNewIssueMock).not.toHaveBeenCalled(); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("uses the selected saved Composio account and clears the previous account's sign-in link", async () => { + composioFixture(); + listComposioAppsMock.mockResolvedValue({ apps: [] }); + refreshComposioAppsMock.mockResolvedValue({ apps: [] }); + listConnectionsMock.mockResolvedValue({ connections: [ + connection({ applicationId: "gateway-app", name: "Work Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }), + connection({ id: "conn-personal", applicationId: "gateway-app", name: "Personal Composio", transport: "mcp_remote", config: { sourceTemplateKey: "composio" } }), + connection({ id: "conn-paused", applicationId: "gateway-app", name: "Paused Composio", enabled: false, config: { sourceTemplateKey: "composio" } }), + ] }); + listAgentsMock.mockRejectedValue(new Error("Agents are unavailable")); + setupComposioAppMock.mockReset().mockResolvedValueOnce({ status: "authorization_required", authorizationUrl: "https://connect.composio.dev/link/personal" }).mockResolvedValueOnce({ status: "connected" }); + await renderBrowse(); + await act(() => container.querySelector('button[aria-label="Connect Circleback"]')!.click()); + const select = document.querySelector('#composio-app-account')!; + expect(Array.from(select.options).map(option => option.textContent)).toEqual(["Work Composio", "Personal Composio", "Connect a new account…"]); + await act(() => { select.value = "conn-personal"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await flushReact(); + await clickButton("Continue", document.querySelector('[role="dialog"]')!); + expect(setupComposioAppMock).toHaveBeenCalledWith("conn-personal", "circleback", { action: "start" }); + expect(document.querySelector('a[href="https://connect.composio.dev/link/personal"]')).toBeTruthy(); + await act(() => { select.value = "conn-notion"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await flushReact(); + expect(document.querySelector('a[href="https://connect.composio.dev/link/personal"]')).toBeNull(); + await clickButton("Continue", document.querySelector('[role="dialog"]')!); + expect(setupComposioAppMock).toHaveBeenLastCalledWith("conn-notion", "circleback", { action: "start" }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(listAgentsMock).not.toHaveBeenCalled(); + expect(openNewIssueMock).not.toHaveBeenCalled(); + listAgentsMock.mockResolvedValue([]); + }); + + it("offers a new Composio account in the dropdown and carries the requested app into setup", async () => { + composioFixture(); + listComposioAppsMock.mockResolvedValue({ apps: [] }); + refreshComposioAppsMock.mockResolvedValue({ apps: [] }); + setupComposioAppMock.mockReset(); + await renderBrowse(); + await act(() => container.querySelector('button[aria-label="Connect Circleback"]')!.click()); + const select = document.querySelector('#composio-app-account')!; + await act(() => { select.value = "__new__"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + await flushReact(); + expect(navigateMock).not.toHaveBeenCalled(); + await clickButton("Connect new account", document.querySelector('[role="dialog"]')!); + expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=composio&targetToolkit=circleback&new=1"); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(setupComposioAppMock).not.toHaveBeenCalled(); + expect(listAgentsMock).not.toHaveBeenCalled(); + }); + + it("returns from Composio gateway setup directly to its app flow even with multiple providers", async () => { + aggregatorCatalogMock.push(indexedApp("HubSpot", ["composio", "arcade"])); + window.history.replaceState({}, "", "/apps?source=composio&targetToolkit=hubspot"); + listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("composio")] }); + listApplicationsMock.mockResolvedValue({ applications: [application({ id: "gateway-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); + listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "gateway-app", name: "Composio account", config: { sourceTemplateKey: "composio" } })] }); + await renderBrowse(); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain("through Composio")); + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain("Which service would you like to use?"); + expect(document.querySelector('#composio-app-account')?.value).toBe("conn-notion"); + expect(document.querySelector('#composio-app-agent')).toBeNull(); + expect(openNewIssueMock).not.toHaveBeenCalled(); + }); + it("shows retirement guidance before paused state for an obsolete Composio account", async () => { listApplicationsMock.mockResolvedValue({ applications: [application({ id: "old-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); listConnectionsMock.mockResolvedValue({ connections: [connection({ applicationId: "old-app", enabled: false, healthStatus: "error", transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } })] }); @@ -451,7 +949,7 @@ describe("Connectors landing page", () => { container.querySelector('header input[aria-label="Search connectors"]'), ).toBeTruthy(); expect(container.querySelector("header")?.classList).toContain( - "justify-start", + "items-start", ); expect(container.querySelector("header")?.classList).not.toContain( "justify-end", diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index c117743324..af142e9b3f 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,17 +1,23 @@ +import { AGGREGATOR_NAMES, aggregatorManagementUrl, isAppAggregator, type AggregatorAppSnapshot, type AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps"; +import { CatalogSourceFilters, type CatalogSource } from "./CatalogSourceFilters"; +import { ExecutorManagementSetup } from "./ExecutorManagementSetup"; +import { ArcadeDiscoverySetup } from "./ArcadeDiscoverySetup"; +import { AggregatorAppManager } from "./AggregatorAppManager"; import { connectionSetupVerbForApp, isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, } from "@paperclipai/shared"; import { ManagedAiConnectionRow } from "@/components/ai-connections/ManagedAiConnectionDetails"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Check, ChevronRight, ClipboardPaste, Clock3, + ExternalLink, Link2, Loader2, MoreHorizontal, @@ -20,7 +26,7 @@ import { ServerCog, Trash2, } from "lucide-react"; -import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; +import type { ComposioAppSnapshot, ToolApplication, ToolConnection } from "@paperclipai/shared"; import { getAppDefinitionForUrl, isMemoryConnectorId, @@ -28,6 +34,8 @@ import { isToolConnectionAttentionHealth, aiSubscriptionNeedsIsolatedLogin, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + CONNECTABLE_APP_DEFINITIONS, + normalizeConnectionQuery, } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled"; @@ -39,6 +47,7 @@ import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; import { emailApi } from "@/api/email"; +import { useAccountIdentity } from "@/api/companies-query"; import { chatEndpointsApi, type ChatEndpoint, @@ -64,6 +73,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { AGGREGATOR_APP_CATALOG, aggregatorAppIdentity, findComposioCatalogApp, type AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; +import { AggregatorConnectDialog, aggregatorAppConnectHref } from "./AggregatorConnectDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { AppLogo } from "./AppLogo"; @@ -101,8 +113,13 @@ type ConnectorRowModel = { applications: ToolApplication[]; connections: ToolConnection[]; chatEndpoints: ChatEndpoint[]; + aggregatorApp?: AggregatorAppCatalogEntry; + upstreamApps?: (ComposioAppSnapshot & Partial>)[]; + upstreamCatalogApp?: AggregatorAppCatalogEntry; }; +export const CATALOG_PAGE_SIZE = 50; + type ConnectionState = { kind: "connected" | "attention" | "paused" | "draft"; label: string; @@ -215,10 +232,15 @@ function connectionRank(connection: ToolConnection): number { function rowRank(row: ConnectorRowModel): number { if ( row.chatEndpoints.some((endpoint) => endpoint.status !== "draft") || - row.connections.some((connection) => connectionRank(connection) === 1) + row.connections.some((connection) => connectionRank(connection) === 1) || + row.upstreamApps?.some((app) => app.status === "connected") ) return 2; - return row.connections.length > 0 || row.chatEndpoints.length > 0 ? 1 : 0; + return isInstalled(row) ? 1 : 0; +} + +function isInstalled(row: ConnectorRowModel) { + return row.connections.length > 0 || row.chatEndpoints.length > 0 || Boolean(row.upstreamApps?.some(app => app.accounts.length > 0)); } function connectorAction( @@ -230,6 +252,7 @@ function connectorAction( href: string | null; title?: string; } { + if (row.aggregatorApp && isInstalled(row)) return { label: "Manage", href: null }; const applicationId = row.applications[0]?.id ?? null; const chatHref = (row.slug === "agentmail" || chatConnectorsEnabled) ? chatConnectHref( @@ -300,10 +323,20 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const queryClient = useQueryClient(); const { pushToast } = useToast(); const { selectedCompanyId } = useCompany(); + const { userId: viewingUserId, settled: identitySettled } = useAccountIdentity(); const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled(); const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const [source, setSource] = useState("paperclip"); + const [expandedSearch, setExpandedSearch] = useState(false); + const [executorToConfigure, setExecutorToConfigure] = useState(null); + const [arcadeToConfigure, setArcadeToConfigure] = useState(null); + const catalogTop = useRef(null); + const [aggregatorToConnect, setAggregatorToConnect] = useState(null); + const [aggregatorToManage, setAggregatorToManage] = useState<{ app: AggregatorAppCatalogEntry; connectionId?: string } | null>(null); + const [initialAggregatorProvider, setInitialAggregatorProvider] = useState(); const [connectionToRemove, setConnectionToRemove] = useState(null); @@ -312,6 +345,15 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne return () => setBreadcrumbs([]); }, [setBreadcrumbs]); + useEffect(() => { setQuery(""); setPage(1); setSource("paperclip"); setExpandedSearch(false); setArcadeToConfigure(null); setAggregatorToConnect(null); setAggregatorToManage(null); setInitialAggregatorProvider(undefined); setConnectionToRemove(null); }, [selectedCompanyId]); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("source") === "composio") { + setAggregatorToConnect((params.get("targetToolkit") ? findComposioCatalogApp(params.get("targetToolkit")!) : null) ?? null); + setInitialAggregatorProvider("composio"); + } + }, [selectedCompanyId]); + const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), @@ -339,7 +381,36 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const aggregatorGateways = useMemo(() => (connectionsQuery.data?.connections ?? []).filter(connection => + isAppAggregator(appConnectionSourceSlug(connection)) && connection.transport === "mcp_remote" && + connection.status !== "archived" && !isRetiredComposioConnection(connection)), [connectionsQuery.data]); + const aggregatorAccountsQueries = useQueries({ queries: aggregatorGateways.map(connection => ({ + queryKey: queryKeys.tools.aggregatorApps(connection.id, viewingUserId), + queryFn: () => toolsApi.listAggregatorApps(connection.id), + enabled: identitySettled, + staleTime: Infinity, refetchOnWindowFocus: "always" as const, retry: false, + refetchInterval: (query: { state: { data?: { sync?: { status: string } } } }) => query.state.data?.sync?.status === "syncing" ? 1500 : 60_000, + })) }); + const upstreamApps = aggregatorAccountsQueries.flatMap((result, index) => { + const syncFailed = queryClient.getQueryState([...queryKeys.tools.aggregatorApps(aggregatorGateways[index].id, viewingUserId), "sync"])?.status === "error"; + return (identitySettled ? result.data?.apps ?? [] : []).map(snapshot => result.isError || syncFailed || result.data?.sync.status === "error" ? { ...snapshot, errorAt: snapshot.errorAt ?? new Date().toISOString() } : snapshot); + }); + const upstreamAppsBySlug = new Map(); + for (const snapshot of upstreamApps) { + if (snapshot.accounts.length === 0) continue; + upstreamAppsBySlug.set(snapshot.appSlug, [...(upstreamAppsBySlug.get(snapshot.appSlug) ?? []), snapshot]); + } + // Custom Executor integrations join discovery only after an observed account exists. + const managedCatalog = AGGREGATOR_APP_CATALOG.map(app => ({ ...app, routes: [...app.routes] })); + for (const snapshot of upstreamApps) { + if (!snapshot.accounts.length) continue; + let app = managedCatalog.find(app => app.slug === snapshot.appSlug); + const route = { provider: snapshot.provider, toolkit: snapshot.toolkit, logoUrl: "", docsUrl: "" }; + if (!app) { app = { slug: snapshot.appSlug, name: snapshot.appName, aliases: [snapshot.toolkit], routes: [] }; managedCatalog.push(app); } + if (!app.routes.some(route => route.provider === snapshot.provider)) app.routes.push(route); + } const removeConnection = useMutation({ + retry: false, mutationFn: async (target: ConnectionRemovalTarget) => { if (target.kind === "chat") { if (target.provider === "agentmail") { @@ -384,6 +455,10 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne tone: "error", }), }); + function requestConnectionRemoval(target: ConnectionRemovalTarget) { + removeConnection.reset(); + setConnectionToRemove(target); + } const gallery = ( (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[] @@ -400,6 +475,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), [userDirectoryQuery.data], ); + const connectionById = useMemo(() => new Map((connectionsQuery.data?.connections ?? []).map(connection => [connection.id, connection])), [connectionsQuery.data]); const rows = useMemo(() => { const activeConnections = (connectionsQuery.data?.connections ?? []).filter( @@ -599,8 +675,28 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne target.chatEndpoints.push(endpoint); } - return [...rowsBySlug.values(), ...customRows] - .filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug)) + // Native definitions take precedence even when temporarily hidden or unavailable. + // Public metadata never creates an application, account, or installed status. + const nativeIdentities = new Set([...CONNECTABLE_APP_DEFINITIONS, ...(galleryQuery.data?.apps ?? [])].flatMap((app) => + [appDefinitionName(app), appDefinitionSlug(app)].map(aggregatorAppIdentity))); + for (const app of managedCatalog) { + const snapshots = upstreamAppsBySlug.get(app.slug) ?? []; + const aliases = new Set([app.name, app.slug, ...app.aliases].map(aggregatorAppIdentity)); + const nativeRow = [...rowsBySlug.values()].find(row => aliases.has(aggregatorAppIdentity(row.slug)) || aliases.has(aggregatorAppIdentity(row.name))); + if (nativeRow) { nativeRow.upstreamApps = [...(nativeRow.upstreamApps ?? []), ...snapshots]; nativeRow.upstreamCatalogApp = app; } + } + const aggregatorRows: ConnectorRowModel[] = managedCatalog.filter((app) => + ![app.name, app.slug, ...app.aliases].some((alias) => nativeIdentities.has(aggregatorAppIdentity(alias)))) + .map((app) => ({ + key: `aggregator:${app.slug}`, slug: app.slug, name: app.name, + description: `Connect through ${app.routes.map((route) => AGGREGATOR_NAMES[route.provider]).join(" or ")}.`, + brandKey: app.slug, logoUrl: app.routes[0]?.logoUrl, entry: null, + applications: [], connections: [], chatEndpoints: [], aggregatorApp: app, + upstreamApps: upstreamAppsBySlug.get(app.slug) ?? [], upstreamCatalogApp: app, + })); + + return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] + .filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug) || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ ...row, connections: [...row.connections].sort( @@ -614,6 +710,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne .sort( (left, right) => rowRank(right) - rowRank(left) || + Number(Boolean(left.aggregatorApp)) - Number(Boolean(right.aggregatorApp)) || left.name.localeCompare(right.name, undefined, { sensitivity: "base", }) || @@ -625,25 +722,86 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne chatConnectorsEnabled, connectionsQuery.data, gallery, + galleryQuery.data, + upstreamAppsBySlug, ]); const trimmed = query.trim().toLocaleLowerCase(); const visibleRows = useMemo(() => { - if (!trimmed) return rows; - return rows.filter( - (row) => - row.name.toLocaleLowerCase().includes(trimmed) || - row.description.toLocaleLowerCase().includes(trimmed) || - row.connections.some((connection) => - connection.name.toLocaleLowerCase().includes(trimmed), - ) || - row.chatEndpoints.some((endpoint) => - endpoint.assignedAgentName.toLocaleLowerCase().includes(trimmed), - ), - ); - }, [rows, trimmed]); + const scoped = rows.filter((row) => { + if (source === "all") return true; + if (source === "installed") return isInstalled(row); + if (source === "paperclip") return !row.aggregatorApp || isInstalled(row); + return isAppAggregator(source) && ( + row.slug === source && isInstalled(row) + || row.upstreamApps?.some(snapshot => snapshot.provider === source && snapshot.accounts.length > 0) + || row.aggregatorApp?.routes.some(route => route.provider === source) + ); + }); + if (!trimmed) return scoped; + const terms = normalizeConnectionQuery(trimmed).split(" ").filter(Boolean); + return scoped.filter((row) => { + const text = normalizeConnectionQuery([row.name, row.slug, row.description, + ...(row.aggregatorApp?.aliases ?? []), ...row.connections.map((connection) => connection.name), + ...(row.upstreamApps ?? []).flatMap(app => app.accounts.map(account => account.alias)), + ...row.chatEndpoints.map((endpoint) => endpoint.assignedAgentName)].join(" ")); + return terms.every((term) => text.includes(term)); + }); + }, [rows, trimmed, source]); + const installedRows = visibleRows.filter(isInstalled); + const catalogRows = visibleRows.filter(row => !isInstalled(row)); + const pageCount = Math.max(1, Math.ceil(catalogRows.length / CATALOG_PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const pageStart = (currentPage - 1) * CATALOG_PAGE_SIZE; + const paginatedRows = [...installedRows, ...catalogRows.slice(pageStart, pageStart + CATALOG_PAGE_SIZE)]; + const aggregatorSyncQueries = useQueries({ queries: aggregatorGateways.map((connection, index) => ({ + queryKey: [...queryKeys.tools.aggregatorApps(connection.id, viewingUserId), "sync"], + queryFn: async () => { + const result = await toolsApi.syncAggregatorApps(connection.id); + queryClient.setQueryData(queryKeys.tools.aggregatorApps(connection.id, viewingUserId), result); + return result; + }, + enabled: identitySettled && aggregatorAccountsQueries[index]?.isSuccess === true && aggregatorAccountsQueries[index].data?.discovery.availability === "available", + staleTime: 60_000, refetchInterval: 60_000, refetchOnMount: "always" as const, refetchOnWindowFocus: "always" as const, retry: false, + })) }); + const accountLoadErrors = aggregatorAccountsQueries.flatMap((result, index) => result.isError || aggregatorSyncQueries[index]?.isError ? [AGGREGATOR_NAMES[appConnectionSourceSlug(aggregatorGateways[index]) as keyof typeof AGGREGATOR_NAMES]] : []); + const refreshAggregator = useMutation({ mutationFn: async (connectionId: string) => { + await toolsApi.refreshCatalog(connectionId); + const result = await toolsApi.syncAggregatorApps(connectionId, true); + queryClient.setQueryData(queryKeys.tools.aggregatorApps(connectionId, viewingUserId), result); + queryClient.setQueryData([...queryKeys.tools.aggregatorApps(connectionId, viewingUserId), "sync"], result); + } }); + const aggregatorRefreshState = new Map(aggregatorGateways.map((connection, index) => [connection.id, + aggregatorAccountsQueries[index]?.data?.sync?.status === "syncing" || (refreshAggregator.isPending && refreshAggregator.variables === connection.id), + ] as const)); + const discoveryByConnection = new Map(aggregatorGateways.map((connection, index) => [connection.id, aggregatorAccountsQueries[index]?.data] as const)); + const aggregatorConnections = useMemo(() => ({ + composio: aggregatorGateways.filter(connection => appConnectionSourceSlug(connection) === "composio"), + arcade: aggregatorGateways.filter(connection => appConnectionSourceSlug(connection) === "arcade"), + executor: aggregatorGateways.filter(connection => appConnectionSourceSlug(connection) === "executor"), + }), [aggregatorGateways]); + function changeQuery(value: string) { + if (source === "paperclip" && !query.trim() && value.trim()) { setSource("all"); setExpandedSearch(true); } + if (expandedSearch && !value.trim()) { setSource("paperclip"); setExpandedSearch(false); } + setQuery(value); setPage(1); + } + function closeAggregatorSetup() { + setAggregatorToConnect(null); + void queryClient.invalidateQueries({ queryKey: ["tools", "aggregator-apps"] }); + if (new URLSearchParams(window.location.search).get("source") === "composio") navigate("/apps", { replace: true }); + } + function connectAggregator(app: AggregatorAppCatalogEntry) { + setInitialAggregatorProvider(undefined); + if (isAppAggregator(source)) app = { ...app, routes: app.routes.filter(route => route.provider === source) }; + const onlyRoute = app.routes.length === 1 ? app.routes[0] : null; + if (onlyRoute && aggregatorConnections[onlyRoute.provider].length === 0) { + navigate(aggregatorAppConnectHref(onlyRoute)); + return; + } + setAggregatorToConnect(app); + } const showCustomConnector = - !trimmed || "connect your own tool custom mcp server".includes(trimmed); + (source === "paperclip" || source === "all") && (!trimmed || "connect your own tool custom mcp server".includes(trimmed)); if (!selectedCompanyId) { return ( @@ -666,19 +824,20 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const nothingMatches = visibleRows.length === 0 && !showCustomConnector; return ( -
-
+
+
setQuery(event.target.value)} + onChange={(event) => changeQuery(event.target.value)} placeholder="Search connectors…" aria-label="Search connectors" className="pl-9" />
+ { setSource(value); setExpandedSearch(false); setPage(1); }} />
{loadFailed ? ( @@ -707,6 +866,8 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
) : null} + {refreshAggregator.isError ?

Couldn’t refresh the gateway. Last known accounts are shown.

: null} + {loading ? (
{Array.from({ length: 6 }).map((_, index) => ( @@ -714,22 +875,30 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne ))}
) : nothingMatches ? ( -

- - No connectors match “{query.trim()}”. -

+
+

No connectors match “{query.trim()}”.

+ +
) : (
- {visibleRows.map((row) => ( + {paginatedRows.map((row) => ( setAggregatorToManage({ app, connectionId })} + onRefreshAggregator={connectionId => refreshAggregator.mutate(connectionId)} + aggregatorRefreshState={aggregatorRefreshState} + discoveryByConnection={discoveryByConnection} + onConfigureArcade={connection => setArcadeToConfigure(connection)} + onConfigureExecutor={connection => setExecutorToConfigure(connection)} /> ))} {showCustomConnector ? ( @@ -738,10 +907,30 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
)} + {accountLoadErrors.length ?

Couldn’t load {Array.from(new Set(accountLoadErrors)).join(" or ")} accounts. Use Refresh in the connection’s menu to try again.

: null} + + {!loading && catalogRows.length > 0 ? : null} + + {aggregatorToConnect && !loading && !loadFailed ? : null} + {aggregatorToManage ? aggregatorToManage.app.routes.some(route => route.provider === appConnectionSourceSlug(connection)))} onClose={() => setAggregatorToManage(null)} /> : null} + + {executorToConfigure ? setExecutorToConfigure(null)} /> : null} + {arcadeToConfigure ? setArcadeToConfigure(null)} /> : null} + { - if (!open && !removeConnection.isPending) setConnectionToRemove(null); + if (!open && !removeConnection.isPending) { + setConnectionToRemove(null); + removeConnection.reset(); + } }} > @@ -750,7 +939,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne Remove {connectionToRemove?.accountName ?? "this"} connection? - {connectionToRemove?.kind === "chat" + {connectionToRemove && Object.values(AGGREGATOR_NAMES).includes(connectionToRemove.providerName as "Composio") + ? `This removes the saved ${connectionToRemove.providerName} connection from Paperclip and agents lose access through it. Your apps and accounts remain connected in ${connectionToRemove.providerName}.` + : connectionToRemove?.kind === "chat" ? `This connection will stop receiving new work from ${connectionToRemove.providerName}. Existing Paperclip tasks and conversation history remain available. This does not delete the app, bot, or account in ${connectionToRemove.providerName}.` : connectionToRemove && connectionToRemove.remainingConnectionCount > 0 @@ -758,7 +949,10 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne : "The saved credentials are deleted and agents lose access immediately. Connecting it again later requires a new sign-in or key."} - + {removeConnection.isError ?

+ {removeConnection.error instanceof Error ? removeConnection.error.message : "Couldn’t remove the connection. Please try again."} +

: null} + Cancel @@ -789,30 +983,55 @@ export function ConnectorCard({ renderAccountDetails, row, userProfileById, + connectionById, onNavigate, onRequestRemove, preselectedAgentId, chatConnectorsEnabled, + onConnectAggregator, + onManageAggregator, + onRefreshComposio, + composioRefreshState, + onRefreshAggregator, + aggregatorRefreshState, + discoveryByConnection, + onConfigureArcade, + onConfigureExecutor, }: { renderAccountDetails?: (connection: ToolConnection) => ReactNode; row: ConnectorRowModel; userProfileById: ReadonlyMap; + connectionById?: ReadonlyMap; onNavigate: (href: string) => void; onRequestRemove: (target: ConnectionRemovalTarget) => void; preselectedAgentId?: string | null; chatConnectorsEnabled: boolean; + onConnectAggregator?: (app: AggregatorAppCatalogEntry) => void; + onManageAggregator?: (app: AggregatorAppCatalogEntry, connectionId?: string) => void; + onRefreshComposio?: (connectionId: string) => void; + composioRefreshState?: ReadonlyMap; + onRefreshAggregator?: (connectionId: string) => void; + aggregatorRefreshState?: ReadonlyMap; + discoveryByConnection?: ReadonlyMap; + onConfigureArcade?: (connection: ToolConnection) => void; + onConfigureExecutor?: (connection: ToolConnection) => void; }) { const action = connectorAction( row, chatConnectorsEnabled, preselectedAgentId, ); + const upstreamAccounts = (row.upstreamApps ?? []).flatMap(snapshot => { + const connection = connectionById?.get(snapshot.connectionId); + return connection ? snapshot.accounts.map((account, index) => ({ connection, account, snapshot, + name: account.alias || (snapshot.accounts.length > 1 ? `${row.name} account ${index + 1}` : `${row.name} account`) })) : []; + }); return (
0 || row.chatEndpoints.length > 0 + isInstalled(row) ? "true" : "false" } @@ -827,19 +1046,34 @@ export function ConnectorCard({ size={36} />
-

{row.name}

+
+

{row.name}

+ {row.aggregatorApp?.routes.map((route) => { + const providerName = AGGREGATOR_NAMES[route.provider]; + const managedSnapshot = row.upstreamApps?.find(app => (app.provider ?? "composio") === route.provider && app.accounts.length > 0); + const managed = Boolean(managedSnapshot); + const label = `${managed ? "Manage" : "Connect"} ${row.name} through ${providerName}, a third-party service`; + return + + {label}; + })} +

- {row.description} + {row.aggregatorApp && row.upstreamApps?.some(app => app.accounts.length > 0) ? `Accounts managed in ${[...new Set(row.upstreamApps?.map(app => AGGREGATOR_NAMES[app.provider ?? "composio"]))].join(" and ")}.` : row.description}

) : null} + {row.upstreamCatalogApp && upstreamAccounts.length > 0 ?
+ {upstreamAccounts.map(({ connection, account, name, snapshot }) => { + const gatewayState = connectionState(connection); + const state: ConnectionState = gatewayState.kind !== "connected" ? gatewayState + : snapshot.freshness === "stale" || snapshot.errorAt || Date.now() - new Date(snapshot.checkedAt).getTime() > 5 * 60_000 + ? { kind: "attention", label: "Not verified", message: "Last known account · Refresh to verify" } + : account.status === "UNVERIFIED" ? { kind: "attention", label: "Not verified", message: "Account found upstream · Authorization not verified" } + : account.status === "ACTIVE" ? { kind: "connected", label: "Connected", message: null } + : account.status === "INITIATED" ? { kind: "draft", label: "Waiting for sign-in", message: "Finish connecting this account." } + : { kind: "attention", label: "Needs sign-in", message: "Sign in again to restore access." }; + const provider = snapshot.provider ?? (isAppAggregator(connection.config?.sourceTemplateKey) ? connection.config?.sourceTemplateKey : "composio"); + const providerName = AGGREGATOR_NAMES[provider]; + const managementUrl = aggregatorManagementUrl(provider, account.managementUrl ?? (typeof connection.config?.managementUrl === "string" ? connection.config?.managementUrl : null)); + const manage = () => onManageAggregator?.(row.upstreamCatalogApp!, connection.id); + return Managed by {providerName} ·} + onOpen={manage} openLabel={`Manage ${name}`}> + + + {managementUrl ? Open in {providerName} + : Open in {providerName}} + + + ; + })} +
: null} {row.chatEndpoints.length > 0 ? (
{row.chatEndpoints.map((endpoint) => ( @@ -964,6 +1230,11 @@ function ConnectionAccountRow({ owner, onNavigate, onRemove, + onRefreshComposio, + refreshingComposio, + discovery, + onConfigureArcade, + onConfigureExecutor, }: { details?: ReactNode; row: ConnectorRowModel; @@ -971,6 +1242,11 @@ function ConnectionAccountRow({ owner: ConnectionOwnerProfile | null; onNavigate: (href: string) => void; onRemove: () => void; + onRefreshComposio?: () => void; + refreshingComposio?: boolean; + discovery?: AggregatorAppsResponse; + onConfigureArcade?: () => void; + onConfigureExecutor?: () => void; }) { const state = connectionState(connection); const actionHref = accountActionHref(row, connection); @@ -981,38 +1257,8 @@ function ConnectionAccountRow({ ); return ( -
-
- -
- - {details} - {state.message ? ( -
- {state.message} -
- ) : null} -
-
- -
-
- Connected by - -
+ {details}{discovery?.discovery.message || discovery?.sync.status === "error" ?

{discovery.discovery.message ?? discovery.sync.error}

: null}} + openLabel={`Open ${accountName} permissions`} onOpen={() => onNavigate(`/apps/${connection.id}/permissions`)}> {state.kind === "attention" || state.kind === "draft" ? ( + {details} + {state.message ? ( +
+ {state.message} +
+ ) : null} +
+
+ +
+
+ {source ?? <>Connected by} +
+ {children}
); diff --git a/ui/src/pages/apps/CatalogSourceFilters.tsx b/ui/src/pages/apps/CatalogSourceFilters.tsx new file mode 100644 index 0000000000..2d4d56dab0 --- /dev/null +++ b/ui/src/pages/apps/CatalogSourceFilters.tsx @@ -0,0 +1,15 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export const CATALOG_SOURCES = ["paperclip", "composio", "arcade", "installed", "all"] as const; +export type CatalogSource = typeof CATALOG_SOURCES[number]; +export const CATALOG_SOURCE_NAMES = { paperclip: "Paperclip", composio: "Composio", arcade: "Arcade", executor: "Executor", installed: "Installed", all: "All" }; + +export function CatalogSourceFilters({ source, onChange }: { source: CatalogSource; onChange: (source: CatalogSource) => void }) { + return ; +} diff --git a/ui/src/pages/apps/ComposioAppManager.tsx b/ui/src/pages/apps/ComposioAppManager.tsx new file mode 100644 index 0000000000..0e46cd551c --- /dev/null +++ b/ui/src/pages/apps/ComposioAppManager.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ExternalLink } from "lucide-react"; +import type { ToolConnection } from "@paperclipai/shared"; +import { findComposioCatalogApp, type AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; +import { toolsApi } from "@/api/tools"; +import { queryKeys } from "@/lib/queryKeys"; +import { COMPOSIO_APP_MANAGEMENT_URL } from "@/lib/aggregator-app-setup"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export function ComposioAppManager({ app, connections, initialConnectionId, onClose }: { + app: AggregatorAppCatalogEntry; connections: ToolConnection[]; initialConnectionId?: string; onClose: () => void; +}) { + const [connectionId, setConnectionId] = useState(initialConnectionId ?? connections[0]?.id ?? ""); + const connection = connections.find(candidate => candidate.id === connectionId); + return { if (!open) onClose(); }}> + Manage {app.name}Accounts and sign-in are managed in Composio. + {connections.length > 1 ?
+
: null} + {connection ? :

This Composio connection is no longer available.

} +
; +} + +function ComposioAccountObservations({ app, connection, onClose }: { app: AggregatorAppCatalogEntry; connection: ToolConnection; onClose: () => void }) { + const queries = useQueryClient(); + const key = queryKeys.tools.composioApps(connection.id); + const accountsQuery = useQuery({ queryKey: key, queryFn: () => toolsApi.listComposioApps(connection.id), staleTime: Infinity, refetchOnWindowFocus: false }); + const snapshots = accountsQuery.data?.apps.filter(candidate => findComposioCatalogApp(candidate.toolkit)?.slug === app.slug) ?? []; + const accounts = snapshots.flatMap(snapshot => snapshot.accounts.map(account => ({ account, snapshot }))); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + async function refresh() { + if (busy) return; + setBusy(true); setError(null); + try { + const toolkits = snapshots.length ? snapshots.map(snapshot => snapshot.toolkit) : [app.routes.find(route => route.provider === "composio")!.toolkit]; + queries.setQueryData(key, await toolsApi.refreshComposioApps(connection.id, toolkits)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn’t check Composio. Try again."); + await queries.invalidateQueries({ queryKey: key }); + } finally { setBusy(false); } + } + return <> + {error || accountsQuery.isError ?

{error ?? "Couldn’t load Composio accounts. Refresh to try again."}

: null} + {accountsQuery.isLoading ?

Loading accounts…

: accounts.length === 0 ?

No connected {app.name} accounts.

:
+ {accounts.map(({ account, snapshot }) =>
+

{account.alias || `${app.name} account`}

+

{snapshot.errorAt || Date.now() - new Date(snapshot.checkedAt).getTime() > 5 * 60_000 + ? "Last known account · Refresh to verify" : account.status === "ACTIVE" ? "Connected" : account.status === "INITIATED" ? "Waiting for sign-in" : "Needs sign-in"}{account.isDefault ? " · Default" : ""}

+
)} +
} +
+

Via “{connection.name}”

+

Composio permissions apply to all apps on this connection.

+
+ +
+
+
+ ; +} diff --git a/ui/src/pages/apps/ComposioAppSetup.tsx b/ui/src/pages/apps/ComposioAppSetup.tsx new file mode 100644 index 0000000000..c388804f63 --- /dev/null +++ b/ui/src/pages/apps/ComposioAppSetup.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ExternalLink } from "lucide-react"; +import type { ComposioAppSetupInput, ComposioAppSetupResult, ToolConnection } from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { useAccountIdentity } from "@/api/companies-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/context/ToastContext"; + +/** Provider authorization is performed by the backend; no task or agent run. */ +export function ComposioAppSetup({ name, toolkit, connections, onClose, onBack, onConnectNew }: { + name: string; toolkit: string; connections: ToolConnection[]; onClose: () => void; + onBack?: () => void; onConnectNew: () => void; +}) { + const queries = useQueryClient(); + const { userId, settled } = useAccountIdentity(); + const { pushToast } = useToast(); + const [selectedConnectionId, setConnectionId] = useState(null); + const connectionId = selectedConnectionId ?? connections[0]?.id ?? "__new__"; + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const connection = connections.find(candidate => candidate.id === connectionId); + + async function submit(input: ComposioAppSetupInput) { + if (busy || !connection || !settled) return; + setConnectionId(connection.id); + setBusy(true); + setError(null); + try { + const next = await toolsApi.setupComposioApp(connection.id, toolkit, input); + setResult(next); + await queries.invalidateQueries({ queryKey: queryKeys.tools.composioApps(connection.id), exact: true }); + await queries.invalidateQueries({ queryKey: queryKeys.tools.aggregatorApps(connection.id, userId), exact: true }); + if (next.status === "connected") { + pushToast({ title: `${name} is connected through Composio.`, tone: "success" }); + onClose(); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn’t configure this app. Try again."); + } finally { setBusy(false); } + } + + return
+
+ + +
+ {error ?

{error}

: null} + {result ?

Finish connecting {name} in Composio, then return here.

: null} + {result?.authorizationUrl ? : null} +
+ +
+ {result && result.status !== "connected" ? : null} + +
+
+
; +} diff --git a/ui/src/pages/apps/ExecutorManagementSetup.tsx b/ui/src/pages/apps/ExecutorManagementSetup.tsx new file mode 100644 index 0000000000..b7236af3b8 --- /dev/null +++ b/ui/src/pages/apps/ExecutorManagementSetup.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ToolConnection } from "@paperclipai/shared"; +import { aggregatorManagementUrl } from "@paperclipai/shared/aggregator-apps"; +import { toolsApi } from "@/api/tools"; +import { useAccountIdentity } from "@/api/companies-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export function ExecutorManagementSetup({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) { + const [url, setUrl] = useState(typeof connection.config?.managementUrl === "string" ? connection.config?.managementUrl : ""); + const queries = useQueryClient(); + const { userId, settled } = useAccountIdentity(); + const save = useMutation({ mutationFn: async () => { + const managementUrl = aggregatorManagementUrl("executor", url.trim()); + if (!managementUrl) throw new Error("Enter an HTTPS console URL without credentials."); + await toolsApi.updateConnection(connection.id, { config: { ...connection.config, managementUrl } }); + await queries.invalidateQueries({ queryKey: queryKeys.tools.aggregatorApps(connection.id, userId) }); + const result = await toolsApi.syncAggregatorApps(connection.id, true); + queries.setQueryData(queryKeys.tools.aggregatorApps(connection.id, userId), result); + }, onSuccess: async () => { await queries.invalidateQueries({ queryKey: queryKeys.tools.connections(connection.companyId) }); onClose(); } }); + return { if (!open && !save.isPending) onClose(); }}> + Executor consoleChoose where to manage accounts for “{connection.name}”. Include your workspace path. +
{ event.preventDefault(); save.mutate(); }}> +
setUrl(event.target.value)} />
+ {save.isError ?

{save.error.message}

: null} + +
+
; +} diff --git a/ui/src/pages/apps/app-detail/AgentConnectionAccess.tsx b/ui/src/pages/apps/app-detail/AgentConnectionAccess.tsx new file mode 100644 index 0000000000..a181ef56a6 --- /dev/null +++ b/ui/src/pages/apps/app-detail/AgentConnectionAccess.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import type { Agent, ToolCatalogEntry, ToolPolicy, ToolProfileWithDetails } from "@paperclipai/shared"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Button } from "@/components/ui/button"; + +export function AgentConnectionAccess({ connectionId, profiles, policies, catalog, agents, canManage, onRemove }: { + connectionId: string; + profiles: ToolProfileWithDetails[]; + policies: ToolPolicy[]; + catalog: ToolCatalogEntry[]; + agents: Agent[]; + canManage: boolean; + onRemove: (profileId: string) => Promise; +}) { + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const grants = profiles.filter(profile => { + const agentId = profile.metadata?.agentId; + return profile.status === "active" + && profile.metadata?.source === "connection_intent" + && profile.metadata.connectionId === connectionId + && typeof agentId === "string" + && profile.profileKey === `connection-intent:${connectionId}:${agentId}` + && profile.bindings.some(binding => binding.targetType === "agent" && binding.targetId === agentId); + }); + if (grants.length === 0) return null; + + async function remove(profileId: string) { + setPending(profileId); + setError(null); + try { + await onRemove(profileId); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn't remove this access grant. Please try again."); + } finally { + setPending(null); + } + } + + return
+
+

Additional agent access

+

These agents can use the listed actions in addition to the permissions above. Removing a grant restores their other permissions.

+
+ {grants.map(profile => { + const agentId = profile.metadata!.agentId as string; + const agent = agents.find(candidate => candidate.id === agentId); + const tools = catalog.filter(tool => profile.entries.some(entry => entry.effect === "include" && entry.catalogEntryId === tool.id)); + return
+
+
+ + {agent?.name ?? "Agent"} +
+ {canManage && } +
+
    + {tools.map(tool => { + const askFirst = policies.some(policy => policy.enabled && policy.policyType === "require_approval" + && policy.config?.source === "connection_intent" + && policy.config.connectionId === connectionId && policy.config.agentId === agentId + && policy.selectors?.catalogEntryId === tool.id); + return
  • + {tool.title || tool.toolName} + {askFirst ? "Ask first" : "Allowed"} +
  • ; + })} +
+
; + })} + {error &&

{error}

} +
; +} diff --git a/ui/src/pages/apps/app-detail/ConnectedAggregatorApps.tsx b/ui/src/pages/apps/app-detail/ConnectedAggregatorApps.tsx new file mode 100644 index 0000000000..01c62fe9ff --- /dev/null +++ b/ui/src/pages/apps/app-detail/ConnectedAggregatorApps.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, RefreshCw } from "lucide-react"; +import type { ToolConnection } from "@paperclipai/shared"; +import { AGGREGATOR_APP_CATALOG } from "@paperclipai/shared/aggregator-app-catalog"; +import { AGGREGATOR_NAMES, type AggregatorAppSnapshot, isAppAggregator } from "@paperclipai/shared/aggregator-apps"; +import { useAccountIdentity } from "@/api/companies-query"; +import { toolsApi } from "@/api/tools"; +import { Button } from "@/components/ui/button"; +import { queryKeys } from "@/lib/queryKeys"; +import { AppLogo } from "../AppLogo"; + +const catalogBySlug = new Map(AGGREGATOR_APP_CATALOG.map(app => [app.slug, app])); + +export function ConnectedAggregatorApps({ connection }: { connection: ToolConnection }) { + const queryClient = useQueryClient(); + const { userId, settled, failed } = useAccountIdentity(); + const queryKey = queryKeys.tools.aggregatorApps(connection.id, userId); + const firstRefreshFor = useRef(null); + const identityKey = JSON.stringify([connection.id, userId]); + const query = useQuery({ + queryKey, + queryFn: () => toolsApi.listAggregatorApps(connection.id), + enabled: settled, + retry: false, + refetchOnWindowFocus: "always", + refetchInterval: query => query.state.data?.sync.status === "syncing" ? 1500 : 60_000, + }); + const refresh = useMutation({ + mutationFn: (_viewingUserId: string | null) => toolsApi.syncAggregatorApps(connection.id, true), + onSuccess: (result, viewingUserId) => queryClient.setQueryData(queryKeys.tools.aggregatorApps(connection.id, viewingUserId), result), + }); + const data = settled ? query.data : undefined; + const provider = data?.provider ?? connection.config?.sourceTemplateKey; + const providerName = isAppAggregator(provider) ? AGGREGATOR_NAMES[provider] : "provider"; + const waitingForFirstRefresh = settled && data?.discovery.availability === "available" && firstRefreshFor.current !== identityKey; + const syncing = waitingForFirstRefresh || refresh.isPending || data?.sync.status === "syncing"; + const loadFailed = query.isError || failed; + const syncFailed = refresh.isError || data?.sync.status === "error"; + const unavailable = data && data.discovery.availability !== "available"; + const apps = useMemo(() => { + const grouped = new Map(); + for (const snapshot of data?.apps ?? []) { + if (!snapshot.accounts.length) continue; + grouped.set(snapshot.appSlug, [...(grouped.get(snapshot.appSlug) ?? []), snapshot]); + } + return [...grouped.values()].sort((a, b) => a[0].appName.localeCompare(b[0].appName)); + }, [data?.apps]); + useEffect(() => { + if (!waitingForFirstRefresh) return; + firstRefreshFor.current = identityKey; + refresh.mutate(userId); + }, [waitingForFirstRefresh, identityKey, refresh.mutate, userId]); + + return
+
+

Connected apps

+ +
+ {loadFailed || syncFailed ?

+ Couldn’t refresh {providerName} apps.{apps.length ? " Last known apps are shown." : " Try refreshing again."} +

: null} + {syncing ?

+ Refreshing apps…{data?.sync.total ? ` ${data.sync.checked} of ${data.sync.total}` : ""} +

: refresh.isSuccess && !syncFailed && !unavailable ?

Apps refreshed.

: null} + {(!settled && !failed) || query.isLoading ?

Loading apps…

+ : apps.length ?
    + {apps.map(snapshots => { + const app = snapshots[0]; + const accounts = [...new Map(snapshots.flatMap(snapshot => snapshot.accounts).map(account => [account.id, account])).values()]; + const stale = loadFailed || syncFailed || unavailable || snapshots.some(snapshot => snapshot.freshness === "stale" || snapshot.errorAt); + const connected = !stale && accounts.some(account => account.status === "ACTIVE"); + const status = stale ? "Not verified" : connected ? "Connected" + : accounts.some(account => account.status === "UNVERIFIED") ? "Not verified" + : accounts.some(account => account.status === "INITIATED") ? "Waiting for sign-in" : "Needs sign-in"; + const logoUrl = catalogBySlug.get(app.appSlug)?.routes.find(route => route.provider === provider)?.logoUrl; + return
  • + +
    +

    {app.appName}

    + {accounts.length > 1 ?

    {accounts.length} accounts

    + : accounts[0]?.alias ?

    {accounts[0].alias}

    : null} +
    + + {connected ? +
  • ; + })} +
: !loadFailed && !syncFailed && !syncing ?

+ {data?.discovery.message ?? `No connected apps found in ${providerName}.`} +

: null} +
; +} diff --git a/ui/storybook/prototypes/ConnectorCatalogNavigation.tsx b/ui/storybook/prototypes/ConnectorCatalogNavigation.tsx new file mode 100644 index 0000000000..9ba0a14d85 --- /dev/null +++ b/ui/storybook/prototypes/ConnectorCatalogNavigation.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import { ChevronLeft, ChevronRight, Search, X } from "lucide-react"; +import { CONNECTABLE_APP_DEFINITIONS, getAppStoreDefinition, type ToolApplication, type ToolConnection } from "@paperclipai/shared"; +import { AGGREGATOR_APP_CATALOG, aggregatorAppIdentity, type AggregatorAppCatalogEntry } from "@paperclipai/shared/aggregator-app-catalog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { CATALOG_PAGE_SIZE, ConnectorCard } from "@/pages/apps/Browse"; +import { appCopyFor } from "@/lib/app-gallery-copy"; +import { CatalogSourceFilters, CATALOG_SOURCE_NAMES as SOURCE_NAMES, type CatalogSource as Source } from "@/pages/apps/CatalogSourceFilters"; + +type Row = ComponentProps["row"]; +const NATIVE_SLUGS = ["notion", "composio", "agentmail", "arcade", "browser-use-cloud", "discord", "executor", "github", "linear", "posthog", "railway", "slack", "telegram", "zapier"]; +const nativeIdentities = new Set(CONNECTABLE_APP_DEFINITIONS.flatMap(app => [app.name, app.slug].map(aggregatorAppIdentity))); +const aggregatorCatalog = AGGREGATOR_APP_CATALOG.filter(app => + ![app.name, app.slug, ...app.aliases].some(alias => nativeIdentities.has(aggregatorAppIdentity(alias)))); +const PROFILES = new Map([["user-board", { label: "Board", image: null }]]); + +function connection(slug: string, name: string): ToolConnection { + return { + id: `preview-${slug}`, companyId: "company-storybook", applicationId: `preview-app-${slug}`, + uid: `preview-${slug}`, name, connectionKind: "managed", connectionPurpose: "tool", ownership: "customer", + transport: "mcp_remote", authKind: "oauth", credentialSource: "paperclip_vault", credentialPolicy: "shared", + status: "active", enabled: true, config: { sourceTemplateKey: slug }, transportConfig: {}, credentialSecretRefs: [], + healthStatus: "ok", healthCheckedAt: new Date(), lastError: null, createdByAgentId: null, + createdByUserId: "user-board", createdAt: new Date(), updatedAt: new Date(), + }; +} + +const NOTION = connection("notion", "Team workspace"); +const COMPOSIO = connection("composio", "Work Composio"); +const ARCADE = connection("arcade", "Work Arcade"); +const EXECUTOR = { ...connection("executor", "Team Executor"), config: { sourceTemplateKey: "executor", managementUrl: "https://executor.sh/team/integrations" } }; +const CONNECTIONS = new Map([NOTION, COMPOSIO, ARCADE, EXECUTOR].map(item => [item.id, item])); + +function nativeRows(): Row[] { + return NATIVE_SLUGS.flatMap(slug => { + const entry = getAppStoreDefinition(slug); + if (!entry) return []; + const saved = slug === "notion" ? NOTION : slug === "composio" ? COMPOSIO : slug === "arcade" ? ARCADE : slug === "executor" ? EXECUTOR : null; + const application: ToolApplication = { + id: `preview-app-${slug}`, companyId: "company-storybook", name: entry.name, + description: entry.description, type: "mcp_http", status: "active", pluginId: null, + ownerAgentId: null, ownerUserId: null, metadata: { sourceTemplateKey: slug }, + archivedAt: null, createdAt: new Date(), updatedAt: new Date(), + }; + return [{ key: `native:${slug}`, slug, name: entry.name, description: slug === "composio" || slug === "arcade" ? `Connect apps through ${entry.name}.` : appCopyFor(slug).tagline, + brandKey: slug, entry, connections: saved ? [saved] : [], + upstreamCatalogApp: slug === "notion" ? AGGREGATOR_APP_CATALOG.find(app => app.slug === "notion") : undefined, + upstreamApps: slug === "notion" ? [COMPOSIO, ARCADE, EXECUTOR].map(gateway => ({ provider: gateway.config!.sourceTemplateKey as "composio" | "arcade" | "executor", appSlug: "notion", appName: "Notion", connectionId: gateway.id, toolkit: "notion", status: "connected" as const, checkedAt: new Date().toISOString(), accounts: (gateway === ARCADE ? ["Work", "Personal"] : ["Work"]).map(alias => ({ id: `${gateway.id}-${alias}`, alias, status: "ACTIVE", isDefault: false, managementUrl: gateway === EXECUTOR ? EXECUTOR.config.managementUrl : undefined })) })) : undefined, + applications: saved ? [application] : [], chatEndpoints: [], logoUrl: entry.branding?.logoUrl, darkLogoUrl: entry.branding?.darkLogoUrl }]; + }); +} + +function aggregatorRow(app: AggregatorAppCatalogEntry, source: Source): Row { + const routes = app.routes.filter(route => source === "all" || route.provider === source); + const scopedApp = { ...app, routes }; + const connected = aggregatorAppIdentity(app.name) === "circleback" && routes.some(route => route.provider === "composio"); + return { + key: `aggregator:${app.slug}`, slug: app.slug, name: app.name, + description: `Connect through ${routes.map(route => SOURCE_NAMES[route.provider]).join(" or ")}.`, + brandKey: app.slug, logoUrl: routes[0]?.logoUrl, entry: null, applications: [], connections: [], chatEndpoints: [], + aggregatorApp: scopedApp, upstreamCatalogApp: scopedApp, + upstreamApps: connected ? [{ connectionId: COMPOSIO.id, toolkit: "circleback_mcp", status: "connected", + checkedAt: new Date().toISOString(), accounts: [{ id: "preview-circleback", alias: "Meeting notes", status: "ACTIVE", isDefault: true }] }] : [], + }; +} + +function installed(row: Row) { + return row.connections.length > 0 || Boolean(row.upstreamApps?.some(app => app.accounts.length > 0)); +} + +/** Design review only: real public catalogs and shipped cards, with local example accounts. */ +export function ConnectorCatalogNavigation({ + initialSource = "paperclip", initialQuery = "", initialPage = 1, pageSize = CATALOG_PAGE_SIZE, +}: { initialSource?: Source; initialQuery?: string; initialPage?: number; pageSize?: number }) { + const expandsInitialSearch = Boolean(initialQuery.trim() && initialSource === "paperclip"); + const [source, setSource] = useState(expandsInitialSearch ? "all" : initialSource); + const [expandedSearch, setExpandedSearch] = useState(expandsInitialSearch); + const [query, setQuery] = useState(initialQuery); + const [page, setPage] = useState(initialPage); + const [previewAction, setPreviewAction] = useState(null); + const headerRef = useRef(null); + const availableRef = useRef(null); + const provider = ["composio", "arcade", "executor"].includes(source); + const native = nativeRows().filter(row => !provider || row.slug === source && installed(row) || row.upstreamApps?.some(app => app.provider === source && app.accounts.length > 0)); + // Saved apps stay visible above discovery, including apps managed through Composio. + const aggregate = aggregatorCatalog + .filter(app => source === "paperclip" ? aggregatorAppIdentity(app.name) === "circleback" + : !provider || app.routes.some(route => route.provider === source)) + .map(app => aggregatorRow(app, provider ? source : "all")); + const term = query.trim().toLocaleLowerCase(); + const matching = [...native, ...aggregate].filter(row => + (source !== "installed" || installed(row)) && (!term || [row.name, row.slug, row.description, + ...row.connections.map(item => item.name), + ...(row.upstreamApps?.flatMap(app => app.accounts.map(account => account.alias)) ?? []), + ...(row.aggregatorApp?.aliases ?? [])].join(" ").toLocaleLowerCase().includes(term))); + const connected = matching.filter(installed).sort((a, b) => Number(Boolean(a.aggregatorApp)) - Number(Boolean(b.aggregatorApp)) || a.name.localeCompare(b.name)); + const available = matching.filter(row => !installed(row)).sort((a, b) => a.name.localeCompare(b.name)); + const pageCount = Math.max(1, Math.ceil(available.length / pageSize)); + const currentPage = Math.min(page, pageCount); + const pageRows = available.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + useEffect(() => { headerRef.current?.scrollIntoView({ block: "start" }); }, [source]); + + function changePage(next: number) { + setPage(next); + requestAnimationFrame(() => availableRef.current?.scrollIntoView({ block: "start" })); + } + + function browse(next: Source) { + setSource(next); + setExpandedSearch(false); + setPage(1); + } + + function changeQuery(value: string) { + // A search from Paperclip includes aggregator apps; explicit filters stay scoped. + if (source === "paperclip" && !query.trim() && value.trim()) { + setSource("all"); + setExpandedSearch(true); + } + if (expandedSearch && !value.trim()) { + setSource("paperclip"); + setExpandedSearch(false); + } + setQuery(value); + setPage(1); + } + + function renderRow(row: Row) { + return setPreviewAction(row.name)} + onRequestRemove={target => setPreviewAction(target.accountName)} + onConnectAggregator={app => setPreviewAction(app.name)} onManageAggregator={app => setPreviewAction(app.name)} + onRefreshAggregator={id => setPreviewAction(`Refresh ${CONNECTIONS.get(id)?.config?.sourceTemplateKey}`)} aggregatorRefreshState={new Map([COMPOSIO, ARCADE, EXECUTOR].map(gateway => [gateway.id, false]))} />; + } + + return
+
+

Connectors

+
+ + changeQuery(event.target.value)} className="pl-9 pr-9" /> + {query ? : null} +
+ +
+ + {connected.length > 0 ?
+

{source === "installed" ? "Installed" : "Connected"}

+
{connected.map(renderRow)}
+
: null} + + {(available.length > 0 || connected.length === 0) ?
+
+

{term ? "Search results" : source === "paperclip" ? "Built into Paperclip" : provider ? `${SOURCE_NAMES[source]} apps` : source === "installed" ? "Installed" : "Available apps"}

+ {available.length.toLocaleString("en-US")} {term ? available.length === 1 ? "result" : "results" : "apps"} +
+ {pageRows.length ?
{pageRows.map(renderRow)}
+ :
+

{source === "installed" ? `No installed apps match “${query.trim()}”.` : `No ${provider ? `${SOURCE_NAMES[source]} ` : ""}apps match “${query.trim()}”.`}

+
+ + {source !== "all" ? : null} +
+
} + {pageCount > 1 ? : null} +
: null} + + !open && setPreviewAction(null)}> + {previewAction} + This preview covers catalog navigation. Account setup and management continue on the Apps page. + + +
; +} diff --git a/ui/storybook/prototypes/ManagedAggregatorAccounts.tsx b/ui/storybook/prototypes/ManagedAggregatorAccounts.tsx new file mode 100644 index 0000000000..926d32d756 --- /dev/null +++ b/ui/storybook/prototypes/ManagedAggregatorAccounts.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Browse } from "@/pages/apps/Browse"; +import { Button } from "@/components/ui/button"; + +/** Production Apps UI and API; only upstream providers are simulated by the disposable fixture server. */ +export function ManagedAggregatorAccounts() { + const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } })); + const [ready, setReady] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + const previous = window.fetch; + let mounted = true; + void previous("http://localhost:4310/fixture").then(response => response.json()).then(({ companyId }) => { + if (!mounted) return; + window.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), window.location.origin); + if (/^\/api\/(companies\/[^/]+\/tools|tool-connections\/)/.test(url.pathname)) { + url.pathname = url.pathname.replace("company-storybook", companyId); + return previous(`http://localhost:4310${url.pathname}${url.search}`, { ...init, credentials: "omit", headers: { "Content-Type": "application/json" } }); + } + return previous(input, init); + }; + setReady(true); + }).catch(() => { if (mounted) setError("Start tests/aggregator-accounts/test-drive.ts to use this full-stack fixture."); }); + return () => { mounted = false; window.fetch = previous; client.clear(); }; + }, [client]); + async function change(upstream: Record) { + await fetch("http://localhost:4310/fixture", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(upstream) }); + } + return
+ + {error ?

{error}

: ready ? :

Connecting to the fixture API…

} +
; +} diff --git a/ui/storybook/prototypes/README.md b/ui/storybook/prototypes/README.md index c39696fe2d..c187b519da 100644 --- a/ui/storybook/prototypes/README.md +++ b/ui/storybook/prototypes/README.md @@ -6,6 +6,28 @@ Run Storybook: pnpm --filter @paperclipai/ui exec storybook dev --port 6010 --host 127.0.0.1 --no-open -c storybook/.storybook ``` +## Connector catalog chips + +**Apps → Catalog source selection → Chips** is the focused catalog preview. +The four text chips are **Paperclip**, **Composio**, **Installed**, and **All**. +The selected chip has a neutral pill background; the other choices are plain text. +Connected accounts stay above discovery. Installed shows only saved native and +Composio apps. All includes every catalog source, including Arcade. Searching +from Paperclip includes aggregator apps; choosing a chip explicitly scopes the +search. Clearing that automatic global search returns to Paperclip, while +explicit All or Installed selections remain selected. + +Stories cover each chip, global and installed-account search, empty results, +native precedence, and mobile. **Switch filters and search** exercises selection, +pagination, installed-only results, and search. The superseded dropdown, sidebar, +and provider-overview explorations have been removed. + +These remain design fixtures, not changes to the production Apps page. They use +shipped `ConnectorCard`, representative native entries, public aggregator catalogs +with native duplicates omitted, and example saved accounts. Eight entries per page +keep review compact; the page-size control also offers 24. Setup and account +mutations are outside this preview. + ## Existing onboarding **Onboarding → Agent arc** mounts the shipped `OnboardingWizard` against API diff --git a/ui/storybook/stories/apps-catalog-source-selection.stories.tsx b/ui/storybook/stories/apps-catalog-source-selection.stories.tsx new file mode 100644 index 0000000000..a665276d96 --- /dev/null +++ b/ui/storybook/stories/apps-catalog-source-selection.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { ConnectorCatalogNavigation } from "../prototypes/ConnectorCatalogNavigation"; + +const meta = { + title: "Apps/Catalog source selection", + component: ConnectorCatalogNavigation, + parameters: { + layout: "fullscreen", + docs: { description: { component: "Focused chip navigation: Paperclip, Composio, Arcade, Installed, and All. Connected accounts remain above the paginated catalog. Uses shipped connection cards, public catalogs with native duplicates omitted, and sample saved accounts. No account authorization or mutations." } }, + }, + argTypes: { + initialSource: { control: "select", options: ["paperclip", "composio", "arcade", "installed", "all"] }, + pageSize: { control: "select", options: [50] }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +// Keep the existing review URL while replacing the tab presentation with chips. +export const SourceTabs: Story = { name: "Chips" }; +export const Composio: Story = { args: { initialSource: "composio" } }; +export const Arcade: Story = { args: { initialSource: "arcade" } }; +export const MultipleManagedAccounts: Story = { args: { initialQuery: "notion" } }; +export const Installed: Story = { args: { initialSource: "installed" } }; +export const All: Story = { args: { initialSource: "all" } }; +export const GlobalSearch: Story = { args: { initialQuery: "calendar" } }; +export const InstalledSearch: Story = { args: { initialSource: "installed", initialQuery: "Meeting notes" } }; +export const EmptyInstalledSearch: Story = { args: { initialSource: "installed", initialQuery: "spotify" } }; +export const NativeConnectionWins: Story = { args: { initialQuery: "github" } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } }; + +export const SwitchFiltersAndSearch: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const filters = within(canvas.getByRole("navigation", { name: "App filters" })); + await userEvent.click(filters.getByRole("button", { name: "Composio" })); + await expect(filters.getByRole("button", { name: "Composio" })).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByRole("list", { name: "Connected connectors" })).toHaveTextContent("Meeting notes"); + await userEvent.click(canvas.getByRole("button", { name: "Next page" })); + await expect(canvas.getByRole("navigation", { name: "Catalog pages" })).toHaveTextContent("51–100"); + await userEvent.click(filters.getByRole("button", { name: "Installed" })); + await expect(canvas.queryByRole("list", { name: "Available connectors" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("navigation", { name: "Catalog pages" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("list", { name: "Connected connectors" })).toHaveTextContent("Team workspace"); + await userEvent.type(canvas.getByRole("textbox", { name: "Search connectors" }), "Meeting notes"); + await expect(filters.getByRole("button", { name: "Installed" })).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByRole("button", { name: "Manage Circleback" })).toBeVisible(); + await expect(canvas.queryByRole("heading", { name: "Notion" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Clear search" })); + await userEvent.click(filters.getByRole("button", { name: "Paperclip" })); + await userEvent.type(canvas.getByRole("textbox", { name: "Search connectors" }), "calendar"); + await expect(filters.getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true"); + await expect(canvas.getByRole("list", { name: "Available connectors" })).toHaveTextContent("Microsoft Outlook Calendar"); + await userEvent.click(canvas.getByRole("button", { name: "Clear search" })); + await expect(filters.getByRole("button", { name: "Paperclip" })).toHaveAttribute("aria-pressed", "true"); + await userEvent.click(filters.getByRole("button", { name: "All" })); + await userEvent.type(canvas.getByRole("textbox", { name: "Search connectors" }), "github"); + await expect(canvas.getByRole("heading", { name: "GitHub" })).toBeVisible(); + await expect(canvas.queryByRole("button", { name: /Connect GitHub through/ })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Clear search" })); + await expect(filters.getByRole("button", { name: "All" })).toHaveAttribute("aria-pressed", "true"); + }, +}; diff --git a/ui/storybook/stories/apps-managed-accounts.stories.tsx b/ui/storybook/stories/apps-managed-accounts.stories.tsx new file mode 100644 index 0000000000..fdff3c42e0 --- /dev/null +++ b/ui/storybook/stories/apps-managed-accounts.stories.tsx @@ -0,0 +1,6 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManagedAggregatorAccounts } from "../prototypes/ManagedAggregatorAccounts"; +const meta = { title: "Apps/Managed accounts", component: ManagedAggregatorAccounts, parameters: { layout: "fullscreen", docs: { description: { component: "Production Apps page using production API routes and a disposable PostgreSQL database. Upstream Arcade, Composio and Executor responses are fixtures. Start tests/aggregator-accounts/test-drive.ts, then use the upstream controls and each gateway’s Refresh action." } } } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const FullStackTestDrive: Story = {}; diff --git a/ui/storybook/stories/in-feed-connections.stories.tsx b/ui/storybook/stories/in-feed-connections.stories.tsx index 455c5c85e3..dbcff61538 100644 --- a/ui/storybook/stories/in-feed-connections.stories.tsx +++ b/ui/storybook/stories/in-feed-connections.stories.tsx @@ -8,6 +8,8 @@ import { ConnectionIntentInteractionBody } from "@/features/connections/Connecti import { ConnectionSetupFlow, ConnectionSetupCompletionScreen, AccessStep, OAuthConnectStateScreen, type OAuthConnectPhase } from "@/features/connections/ConnectionSetupFlow"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; +import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; import { useNavigate } from "@/lib/router"; import { pendingConnectionIntentInteraction as pending, @@ -17,6 +19,8 @@ import { declinedConnectionIntentInteraction as declined, expiredConnectionIntentInteraction as expired, supersededConnectionIntentInteraction as superseded, + pendingConnectionAccessInteraction as accessPending, + grantedConnectionAccessInteraction as accessGranted, } from "@/fixtures/issueThreadInteractionFixtures"; const notion = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!; @@ -31,7 +35,7 @@ const connection = { createdByAgentId: null, createdByUserId: "user-board", createdAt: new Date("2026-09-07"), updatedAt: new Date("2026-09-07"), } satisfies ToolConnection; -type Scenario = { email?: boolean; ai?: boolean; missingAiAccount?: "anthropic" | "openai"; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; +type Scenario = { email?: boolean; access?: boolean; ai?: boolean; missingAiAccount?: "anthropic" | "openai"; ownerOnly?: boolean; checking?: boolean; count?: number; loading?: boolean; loadError?: boolean; completeError?: boolean; submitting?: boolean; denied?: boolean }; const meta: Meta = { title: "Connections/In-task connections", parameters: { layout: "padded" }, @@ -45,7 +49,7 @@ const meta: Meta = { channel.on("unhandledErrorsWhilePlaying", reportPlayError); const original = window.fetch; const scenario = (parameters.connectionScenario ?? {}) as Scenario; - let current = structuredClone(scenario.email ? emailPending : scenario.missingAiAccount ? missingAiInteraction(scenario.missingAiAccount) : scenario.ai ? aiPending : pending); + let current = structuredClone(scenario.access ? accessPending : scenario.email ? emailPending : scenario.missingAiAccount ? missingAiInteraction(scenario.missingAiAccount) : scenario.ai ? aiPending : pending); window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); if (scenario.missingAiAccount) { @@ -77,12 +81,16 @@ const meta: Meta = { ? Response.json({ error: "This key could not be verified. Check it and try again." }, { status: 422 }) : Response.json({ connectionId: aiAccount.id, grantId: aiAccount.grantId }); if (url.pathname.endsWith("/agents")) return Response.json([{ id: pending.payload.requestingAgentId, companyId: pending.companyId, name: pending.payload.requestingAgentName, status: "active", adapterType: "paperclip_runner", role: "researcher" }]); + if (scenario.access && url.pathname === `/api/agents/${accessPending.payload.requestingAgentId}`) return Response.json({ + ...storybookAgents[0], id: accessPending.payload.requestingAgentId, name: accessPending.payload.requestingAgentName, + }); if (url.pathname.startsWith("/api/connection-intents/")) { if (url.pathname.endsWith("setup-options")) { if (scenario.loading) return new Promise(() => {}); if (scenario.loadError) return Response.json({ error: "Connection options are temporarily unavailable. Try again." }, { status: 503 }); return Response.json({ version: 1, interaction: current, requestedAgentId: pending.payload.requestingAgentId, - service: { service: "notion", name: "Notion", state: "available", methods: [] }, + service: { service: current.payload.serviceSlug, name: current.payload.serviceName, state: "available", methods: [] }, + ...(scenario.access ? { canGrantAccess: true } : {}), ...(scenario.ai ? { aiConnection: { provider: scenario.missingAiAccount ?? "openrouter", method: "api_key", mode: "responsible_user" }, ...(scenario.missingAiAccount ? {} : { aiRepair: { connection: aiAccount, canReconnect: !scenario.ownerOnly } }) } : {}), ...(scenario.email ? { emailSetup: { credentialConnectionId: null, readyConnectionId: null } } : {}), existingConnections: Array.from({ length: scenario.count ?? 0 }, (_, i) => ({ ...connection, id: `${connection.id.slice(0, -1)}${i}`, name: i ? "Team Notion workspace" : connection.name })), @@ -90,8 +98,8 @@ const meta: Meta = { } if (scenario.submitting) return new Promise(() => {}); if (scenario.completeError || scenario.denied) return Response.json({ error: scenario.denied ? "You no longer have permission to share this connection." : "Connection has no permitted tools. Review action permissions and try again." }, { status: scenario.denied ? 403 : 409 }); - if (url.pathname.endsWith("decline")) current = { ...declined, id: pending.id, payload: current.payload }; - else if (url.pathname.endsWith("complete")) current = { ...connected, id: pending.id, payload: current.payload }; + if (url.pathname.endsWith("decline")) current = { ...declined, id: current.id, payload: current.payload }; + else if (url.pathname.endsWith("complete")) current = { ...connected, id: current.id, payload: current.payload }; else if (url.pathname.endsWith("phase")) current = { ...current, payload: { ...current.payload, phase: "needs_retry" } }; return Response.json(current); } @@ -110,6 +118,11 @@ type Story = StoryObj; function Card({ interaction = pending, otherUser = false }: { interaction?: ConnectionIntentInteraction; otherUser?: boolean }) { const { data } = useQuery({ queryKey: ["issues", "interactions", interaction.id], initialData: [interaction], enabled: false, queryFn: async () => [interaction] }); + if (interaction.payload.accessRequest) return ; return ; } function Host({ children }: { children: React.ReactNode }) { @@ -152,6 +165,10 @@ export const AgentMailInvalidKey: Story = { ...card(emailPending, { email: true, completeError: true }), play: enterEmailKey, }; export const NewConnection = card(); +export const AgentAccess = card(accessPending, { access: true }); +export const AgentAccessGranted = card(accessGranted, { access: true }); +export const AgentAccessDeclined = card({ ...accessPending, status: "rejected", result: { version: 1, outcome: "declined" } }, { access: true }); +export const AgentAccessNarrow: Story = { ...AgentAccess, globals: { viewport: { value: "mobile1", isRotated: false } } }; export const EligibleReuse = card(pending, { count: 1 }); export const Authorizing = card(authorizing); export const RetryRequired = card(retry);