refactor(connections): retire the legacy Composio broker (#13758)

## Thinking Path

> - Paperclip manages agents and their access to external tools.
> - Connectors expose these tools through a governed MCP gateway.
> - PR #13755 added a direct Composio MCP connection behind the
experimental MCP aggregators flag.
> - The old project API-key broker still created toolkit child
connections and showed a separate Services tab.
> - Keeping both paths leaves obsolete setup and session code in the
product.
> - This change removes the broker and preserves direct MCP setup,
credentials, permissions, and execution.
> - Saved legacy records fail closed and remain available for explicit
removal.

## Linked Issues or Issue Description

Related: #13755. This retirement supersedes the legacy-path fixes
proposed in #12630, #12632, #12634, and #12906. It does not close those
PRs.

**What existing behavior does this improve?**

Composio connector setup, management, and runtime dispatch.

**Current behavior**

Composio offers both direct MCP and a project API-key broker. The broker
mints sessions and creates one child connection per toolkit.

**Proposed behavior**

Offer only direct MCP. Remove the toolkit Services UI, REST routes, API
client, and session broker. Block saved legacy parent and child records
from discovery, execution, health checks, reconnect, and OAuth. Preserve
their records and credentials until the operator removes each
connection.

**Reason and benefit**

The direct MCP connector becomes the single supported Composio workflow.
Provider accounts remain managed in Composio.

## What Changed

- Remove the API-key catalog method and its generated-source definition.
- Delete Composio broker clients, session creation, account
synchronization, child lifecycle, and toolkit routes.
- Remove the Services tab, service rows, child provenance, and
cascade-removal controls. Keep Vercel provenance intact.
- Retain a shared retirement guard for stored legacy records. Show
Retired status and replacement/removal guidance in the connection list
and details; hide obsolete runtime controls.
- Preserve the experimental MCP aggregators flag and direct MCP
infrastructure.
- Replace broker fixtures with retirement tests and extend direct
Composio catalog/reconnect coverage.

## Verification

- Focused shared, server, and UI tests passed with one worker. Server
retirement tests use a name filter; no full local test suite was run, as
requested.
- Server and UI TypeScript checks passed.
- Token gates and UI build passed.
- Real browser: opened the saved Composio connection, refreshed all 11
tools, and ran the provider's read-only GitHub account-list operation
through the standard Test dialog as an agent. The provider returned
success using the existing OAuth credentials.
- See `doc/connections/COMPOSIO-BROKER-RETIREMENT.md` for scope and live
evidence.
- Storybook build passed. A fresh real agent used
`COMPOSIO_SEARCH_TOOLS` and `COMPOSIO_MULTI_EXECUTE_TOOL` to return the
actual Paperclip DeepWiki hierarchy: one success, zero errors. Gateway
audit records confirm both calls succeeded.
- Browser retirement check: a credential-free legacy fixture showed the
guidance, opened the direct MCP replacement flow, and was removed
through the standard confirmation.
- Focused regressions for the experimental settings copy and exact
OpenAPI route coverage passed. All latest-head CI checks passed (54
successful, two intentionally skipped); Greptile scored 5/5 with no
unresolved review threads. The PR has no merge conflicts.

## Risks

This intentionally breaks the old Composio project API-key and
child-connection workflow. Existing legacy records cannot run, even if
their stored status is active. Operators must create a new direct MCP
connection and choose access rules; credentials and grants are not
migrated. Remove each old record separately to delete its credentials.
No schema migration or data deletion runs automatically. Direct MCP
connections keep their existing grants and secrets.

## Model Used

OpenAI GPT-6 via Codex, with reasoning, code execution, and browser
tools. The exact runtime variant and context-window size are not exposed
in this session.

## 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
- [x] 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
- [x] 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-21 13:57:35 -05:00
1 parent 1483bb8bcf
commit b82661b561
48 files changed
+258 -3391

No files matched your search

@@ -0,0 +1,25 @@
# Composio broker retirement
September 21, 2026
Composio now has one Paperclip catalog method: direct MCP. Enable **Settings → Experimental → MCP aggregators**, then connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections.
The project API-key method, toolkit-management API routes, Services tab, connected-account synchronization, and session-minting broker have been removed. There is no automatic credential or grant migration.
Previously saved broker and child records are retained for inspection and explicit removal. They appear as Retired with replacement guidance, are absent from gateway discovery, and cannot execute or refresh tools, reconnect, or start OAuth. Add a new Composio MCP connection and choose its access rules. Remove each obsolete record using the normal removal action to delete its credentials and revoke its grants. Removing a legacy parent does not automatically remove other saved records.
No database migration or new tables are required. Direct MCP connections are identified separately from the old REST API parent and `provider: composio` child markers, so their existing vault secrets, grants, catalogs, and permissions remain in place.
## Verification
Focused fixtures cover removal of the API-key catalog method and toolkit routes, retirement of stored parent/child records without upstream calls, explicit credential cleanup, rejection of previously discovered child tools, and direct MCP catalog refresh/reconnect with preserved Off/Ask choices and company isolation. Browser acceptance is recorded below after testing the existing live Composio Connect account.
### Live browser regression
Used the existing isolated connector lab with fresh development data, rebuilt UI, and server on the cleanup branch. Opened Connectors → saved Composio → Permissions. Refresh actions returned the same 11 tools with access intact. The ordinary Test dialog ran `COMPOSIO_MANAGE_CONNECTIONS` with GitHub's `list` operation as the test agent; it returned `successful: true`, no provider error, and the account list in 2.0 seconds. No new sign-in or credentials were needed. This exercises the retained OAuth grant and direct MCP gateway, not a mocked provider.
Functional result: catalog refresh and real gateway Test passed. UX result: the existing Permissions/Test flow remained usable and no broker Services tab appeared. This focused regression does not repeat every provider's original acceptance matrix or claim new account authorization.
A fresh real-agent follow-up also passed: `COMPOSIO_SEARCH_TOOLS` discovered `DEEPWIKI_MCP_READ_WIKI_STRUCTURE`, then `COMPOSIO_MULTI_EXECUTE_TOOL` returned the Paperclip documentation hierarchy (13 top-level sections, 62 subsections; one success, zero errors). The gateway audit independently records both invocations as succeeded on the new run. No provider accounts were modified. Server/UI typechecks, token gates, UI build, Storybook build, and the focused tests passed; the full local suite was intentionally not run.
The retired-record UI was also exercised with a disposable, credential-free legacy fixture in the isolated lab. The list showed Retired rather than Paused; details displayed replacement/removal guidance without obsolete runtime controls. Add Composio MCP connection opened the normal Access → Connect flow with the direct MCP URL and authentication fields. The fixture was removed through the normal confirmation UI afterward.
@@ -6,7 +6,7 @@ Test environment: an isolated development worktree with a fresh database and org
## Experimental rollout
Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. Legacy Composio API-key/child connections are unchanged. An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants.
Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. The legacy Composio API-key broker and child connections have since been retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md). An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants.
Focused regression tests cover flag defaults, persistence, managed metadata, cached catalog visibility, all four direct setup routes, and server-side rejection before network/credential writes.
@@ -66,7 +66,7 @@ Storybook links: `apps-connections-zapier--complete-setup-journey`, `apps-connec
PR review regressions: expired MCP sessions now trigger one safe discovery handshake; action calls fail visibly and wait for an explicit retry. Provider handoff detection recognizes protocol/provider envelopes rather than arbitrary app-data statuses. Buffered transports preserve response-ID matching, size limits, and distinct error codes. Dedicated regression checks and selected existing connector/gateway cases pass. Ordinary connector resume/reconnect continues through its existing controller.
Aggregator action risk is conservative: an unfamiliar or renamed tool defaults to write risk even if the provider advertises it as read-only. Only an explicit reviewed allowlist receives read risk; annotations can still escalate it. This affects risk labels and risk-based governance, not the approved default-enabled behavior or saved Off/Ask first choices. Legacy Composio child connections retain their existing classification path.
Aggregator action risk is conservative: an unfamiliar or renamed tool defaults to write risk even if the provider advertises it as read-only. Only an explicit reviewed allowlist receives read risk; annotations can still escalate it. This affects risk labels and risk-based governance, not the approved default-enabled behavior or saved Off/Ask first choices.
- Paste Zapier's generated Full URL into the already-open local Zapier setup field (not chat), then complete its browser Test, agent, governance, refresh, and lifecycle acceptance. Do not rotate the displayed credential unnecessarily.
- Optional Composio GitHub account authorization awaits the user's GitHub verification. The no-auth DeepWiki app path is proven; GitHub app execution is not claimed.
@@ -33,11 +33,10 @@ describe("tool app gallery URL matching", () => {
expect(getAppDefinitionForUrl("https://drivemcp.googleapis.com/mcp/v1")?.slug).toBe("google-drive");
});
it("lists Composio Connect alongside the legacy API-key method", () => {
it("offers only direct MCP for Composio", () => {
const composio = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio");
expect(composio?.methods).toEqual([
expect.objectContaining({ key: "mcp", transport: "mcp_remote", ownershipModes: ["dcr", "customer"] }),
expect.objectContaining({ key: "api-key", transport: "rest_api", auth: "api_key" }),
]);
});
@@ -33,39 +33,6 @@
"guidanceMd": "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.",
"riskTier": "S3",
"label": "Composio Connect"
},
{
"key": "api-key",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use a project API key from the Composio project that owns the toolkits and connected accounts.",
"defaults": {
"serviceHost": "backend.composio.dev"
},
"guidanceMd": "Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "Composio project API key",
"type": "password",
"required": true,
"placeholder": "Paste the Composio API key",
"secret": true
}
],
"keyPlacement": {
"location": "header",
"name": "x-api-key"
},
"consoleLinks": {
"keys": "https://app.composio.dev/",
"settings": "https://app.composio.dev/",
"docs": "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions"
}
}
],
"docsUrl": "https://docs.composio.dev/docs/composio-connect"
+1 -1
View File
@@ -119,7 +119,7 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
enableMcpAggregators: {
title: "MCP aggregators",
description:
"Show experimental Zapier, Arcade, Composio Connect, and Executor setup. Existing connections keep running when hidden; legacy Composio connections are unchanged.",
"Show experimental Zapier, Arcade, Composio Connect, and Executor setup. Existing MCP connections keep running when hidden.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
+2
View File
@@ -2770,3 +2770,5 @@ export { configureRailwaySshSchema, type ConfigureRailwaySsh, type RailwaySshSet
export * from "./announcements.js";
export { REMOTE_MCP_CONNECTOR_METHODS, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod, type RemoteMcpConnectorId } from "./remote-mcp-connectors.js";
export { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "./retired-composio.js";
@@ -22,9 +22,9 @@ describe("independent remote MCP connectors", () => {
expect(connectToolAppSchema.safeParse({ galleryKey: provider, connectionMethodKey: methodKey, link: "https://example.com/mcp", authMode: "bearer", credentialValues: { "credentials.authorization": "test-secret", "headers.X-User-ID": "test-user" } }).success).toBe(true);
});
}
it("keeps the legacy Composio API-key broker distinct", () => {
it("removes the legacy Composio API-key method", () => {
expect(isRemoteMcpConnectorMethod("composio", "api-key")).toBe(false);
expect(getConnectableAppDefinition("composio")?.methods.find((method) => method.key === "api-key")?.transport).toBe("rest_api");
expect(getConnectableAppDefinition("composio")?.methods.find((method) => method.key === "api-key")).toBeUndefined();
expect(connectToolAppSchema.safeParse({ galleryKey: "composio", connectionMethodKey: "api-key", authMode: "bearer" }).success).toBe(false);
});
it("allows removing all agent access without changing the tool choices", () => {
+11
View File
@@ -0,0 +1,11 @@
/** Persisted broker records must never fall through to generic MCP dispatch. */
export function isRetiredComposioConnection(connection: {
transport: string;
config?: Record<string, unknown> | null;
}): boolean {
return (connection.config?.sourceTemplateKey === "composio" && connection.transport === "rest_api")
|| connection.config?.provider === "composio";
}
export const RETIRED_COMPOSIO_MESSAGE =
"This legacy Composio connection is no longer supported. Add a new Composio MCP connection from Connectors, then remove this connection. Existing credentials and permissions are not migrated.";
+1 -25
View File
@@ -707,31 +707,7 @@ const apps = [
"productivity",
"composio.dev",
["https://backend.composio.dev/*", "https://connect.composio.dev/*", "https://mcp.composio.dev/*", "https://*.composio.dev/*"],
[method("mcp", "mcp_remote", "none", { serverUrl: "https://connect.composio.dev/mcp" }, "S3", "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", { label: "Composio Connect", ownershipModes: ["dcr", "customer"] }), method(
"api-key",
"rest_api",
"api_key",
{ serviceHost: "backend.composio.dev" },
"S3",
"Create a scoped project API key in Composio. It needs read access to toolkits and auth configs; later service-connection phases also need connected-account and session access.",
{
whenToUse:
"Use a project API key from the Composio project that owns the toolkits and connected accounts.",
credentialFields: [
field(
"apiKey",
"Composio project API key",
"Paste the Composio API key",
),
],
keyPlacement: { location: "header", name: "x-api-key" },
consoleLinks: {
keys: "https://app.composio.dev/",
settings: "https://app.composio.dev/",
docs: "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions",
},
},
)],
[method("mcp", "mcp_remote", "none", { serverUrl: "https://connect.composio.dev/mcp" }, "S3", "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", { label: "Composio Connect", ownershipModes: ["dcr", "customer"] })],
{ featured: true, docsUrl: "https://docs.composio.dev/docs/composio-connect" },
],
[
@@ -40,8 +40,6 @@ import {
} from "./helpers/embedded-postgres.js";
import { toolAccessService } from "../services/tool-access.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { ComposioApiError, type ComposioClient } from "../services/composio.js";
import { createComposioSessionManager } from "../services/composio-session-manager.js";
import { toolAccessPolicyService } from "../services/tool-access-policy.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { errorHandler } from "../middleware/index.js";
@@ -643,176 +641,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.authorization"]);
});
it("stores and validates a Composio API key without returning plaintext", async () => {
const company = await createCompany(db);
const validatedKeys: string[] = [];
const service = toolAccessService(db, {
composioClientFactory: (apiKey) => ({
validateApiKey: async () => { validatedKeys.push(apiKey); },
}) as unknown as ComposioClient,
});
const result = await service.connectGalleryApp(company.id, {
galleryKey: "composio",
connectionMethodKey: "api-key",
credentialValues: { "credentials.apiKey": "ak_composio_fixture" },
});
expect(validatedKeys).toEqual(["ak_composio_fixture"]);
expect(result.catalog).toEqual([]);
expect(result.actions).toEqual({ readOnly: [], canMakeChanges: [] });
expect(result.connection).toMatchObject({
transport: "rest_api",
authKind: "api_key",
healthStatus: "ok",
});
expect(result.connection.healthMessage).toContain("returned its toolkits");
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, result.connectionId));
expect(connection!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(["credentials.apiKey"]);
expect(connection!.credentialRefs).toEqual([
expect.objectContaining({ placement: "header", key: "x-api-key", prefix: null }),
]);
expect(JSON.stringify({ result, connection })).not.toContain("ak_composio_fixture");
});
it("rejects an invalid Composio key and removes the draft and secret", async () => {
const company = await createCompany(db);
const service = toolAccessService(db, {
composioClientFactory: () => ({
validateApiKey: async () => { throw new ComposioApiError("Composio rejected the API key.", 401); },
}) as unknown as ComposioClient,
});
await expect(service.connectGalleryApp(company.id, {
galleryKey: "composio",
connectionMethodKey: "api-key",
credentialValues: { "credentials.apiKey": "bad_composio_fixture" },
})).rejects.toMatchObject({
status: 422,
details: { code: "composio_api_key_rejected" },
});
await expect(db.select().from(toolConnections)).resolves.toHaveLength(0);
await expect(db.select().from(toolApplications)).resolves.toHaveLength(0);
await expect(db.select().from(companySecrets)).resolves.toHaveLength(0);
});
it("creates, refreshes, and disconnects a Composio toolkit child", async () => {
const company = await createCompany(db);
const connectRequests: unknown[] = [];
const sessionRequests: unknown[] = [];
const deletedAccounts: string[] = [];
const client = {
validateApiKey: async () => undefined,
listToolkits: async () => ({ items: [{ slug: "github", name: "GitHub", meta: { tools_count: 1 } }] }),
listAuthConfigs: async () => ({
items: [{
id: "auth-github",
auth_scheme: "OAUTH2",
is_composio_managed: true,
status: "ACTIVE",
toolkit: { slug: "github" },
}],
}),
createConnectLink: async (input: unknown) => {
connectRequests.push(input);
return { link_token: "link-token", redirect_url: "https://connect.composio.test/github", expires_at: "2026-08-21T20:00:00Z" };
},
listConnectedAccounts: async () => ({
items: [{
id: "account-github",
user_id: `paperclip:${company.id}`,
status: "ACTIVE",
toolkit: { slug: "github" },
auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true },
}],
}),
deleteConnectedAccount: async (accountId: string) => { deletedAccounts.push(accountId); },
createSession: async (userId: string, options: unknown) => {
sessionRequests.push({ userId, options });
return {
session_id: "session-github",
mcp: { url: "https://mcp.composio.test/github", headers: { Authorization: "Bearer session-secret" } },
};
},
} as unknown as ComposioClient;
const service = toolAccessService(db, {
composioClientFactory: () => client,
remoteHttpRequest: async (_url, init) => {
expect(new Headers(init.headers).get("authorization")).toBe("Bearer session-secret");
return jsonResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: { tools: [{ name: "GITHUB_LIST_REPOS", description: "List repositories", annotations: { readOnlyHint: true } }] },
});
},
});
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "composio",
connectionMethodKey: "api-key",
credentialValues: { "credentials.apiKey": "ak_composio_fixture" },
});
const listed = await service.listComposioServices(connected.connectionId);
expect(listed.services).toEqual([
expect.objectContaining({
status: "connected",
connectedAccountId: "account-github",
childConnectionId: expect.any(String),
}),
]);
const childId = listed.services[0]!.childConnectionId!;
const [child] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId));
expect(child).toMatchObject({
companyId: company.id,
applicationId: connected.application.id,
transport: "mcp_remote",
status: "active",
enabled: true,
config: {
provider: "composio",
parentConnectionId: connected.connectionId,
toolkitSlug: "github",
connectedAccountId: "account-github",
},
});
await expect(db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, childId))).resolves.toEqual([
expect.objectContaining({ toolName: "GITHUB_LIST_REPOS", status: "active" }),
]);
expect(sessionRequests).toEqual([
expect.objectContaining({ userId: `paperclip:${company.id}`, options: expect.objectContaining({ toolkits: ["github"], mcp: true }) }),
]);
const sessionManager = createComposioSessionManager(db, { composioClientFactory: () => client });
const [readScope, writeScope] = await Promise.all([
sessionManager.ensureSession(childId, { tools: ["GITHUB_LIST_REPOS"] }),
sessionManager.ensureSession(childId, { tools: ["GITHUB_CREATE_ISSUE"] }),
]);
expect(readScope.scopeKey).not.toBe(writeScope.scopeKey);
expect(sessionRequests.slice(1)).toEqual([
expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }),
expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_CREATE_ISSUE"] } } }) }),
]);
await expect(service.startComposioServiceConnect(connected.connectionId, "github", {})).resolves.toMatchObject({
toolkitSlug: "github",
authConfigId: "auth-github",
redirect_url: "https://connect.composio.test/github",
});
expect(connectRequests).toEqual([
expect.objectContaining({ authConfigId: "auth-github", userId: `paperclip:${company.id}` }),
]);
await expect(service.pollComposioService(connected.connectionId, "github")).resolves.toMatchObject({
child: { id: childId },
});
await expect(service.disconnectComposioService(connected.connectionId, "github")).resolves.toMatchObject({
disconnectedAccountIds: ["account-github"],
removedChildIds: [childId],
});
expect(deletedAccounts).toEqual(["account-github"]);
const [archivedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, childId));
expect(archivedChild).toMatchObject({ status: "archived", enabled: false, credentialSecretRefs: [] });
});
it("stores custom header values as secrets and shows only header names", async () => {
installMcpOAuthFixture({
@@ -75,9 +75,12 @@ describe("remote connector lifecycle", () => {
await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true });
}
});
it("vaults generated URLs, paginates discovery, preserves Off/Ask during refresh and reconnect, and isolates companies", async () => {
it.each([
["zapier", "generated-url", "https://mcp.zapier.com/api/v1/connect?token=fixture-secret"],
["composio", "mcp", "https://mcp.composio.dev/session/fixture?token=fixture-secret"],
])("%s vaults session URLs, paginates discovery, preserves Off/Ask during refresh and reconnect, and isolates companies", async (galleryKey, connectionMethodKey, link) => {
const org = await company(); const other = await company(); const remote = remoteFixture();
const input = { galleryKey: "zapier", connectionMethodKey: "generated-url", link: "https://mcp.zapier.com/api/v1/connect?token=fixture-secret", authMode: "none" as const };
const input = { galleryKey, connectionMethodKey, link, authMode: "none" as const };
const connected = await remote.service.connectGalleryApp(org.id, input, actor);
expect(connected.catalog).toHaveLength(3);
expect(JSON.stringify(connected)).not.toContain("fixture-secret");
+33 -128
View File
@@ -85,7 +85,6 @@ import {
} from "../services/tool-gateway.js";
import { toolAccessRoutes } from "../routes/tool-access.js";
import { errorHandler } from "../middleware/index.js";
import type { ComposioClient } from "../services/composio.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
@@ -290,45 +289,6 @@ async function createComposioParentAndChild(
return { parent: parent!, child: child! };
}
function fakeComposioClient(accountStatus: () => string): ComposioClient {
return {
validateApiKey: vi.fn(async () => undefined),
listToolkits: vi.fn(async () => ({
items: [{ slug: "github", name: "GitHub" }],
})),
listAuthConfigs: vi.fn(async () => ({ items: [] })),
createConnectLink: vi.fn(async () => ({
link_token: "link",
redirect_url: "https://composio.test/link",
expires_at: new Date().toISOString(),
})),
listConnectedAccounts: vi.fn(async () => ({
items: [
{
id: "account-github",
user_id: "paperclip:test",
status: accountStatus(),
toolkit: { slug: "github" },
auth_config: {
id: "auth-github",
auth_scheme: "OAUTH2",
is_composio_managed: true,
},
},
],
})),
deleteConnectedAccount: vi.fn(async () => undefined),
createSession: vi.fn(async () => ({
session_id: "session",
mcp: { url: "https://composio.test/mcp" },
})),
resumeSession: vi.fn(async () => ({
session_id: "session",
mcp: { url: "https://composio.test/mcp" },
})),
};
}
// Build a Response-like object that mirrors what `fetch` returns for an MCP
// Streamable HTTP JSON response: `text()`, `json()`, and a `content-type`
// header. Production now reads the body via `text()` + content-type so it can
@@ -5281,99 +5241,44 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
it("degrades a Composio child when its connected account becomes inactive", async () => {
const company = await createCompany(db);
const { child } = await createComposioParentAndChild(db, company.id);
const client = fakeComposioClient(() => "INACTIVE");
const service = createTestToolAccessService(db, {
composioClientFactory: () => client,
});
await expect(service.checkHealth(child.id)).rejects.toMatchObject({
status: 502,
details: {
code: "composio_connected_account_inactive",
connection: expect.objectContaining({
id: child.id,
healthStatus: "degraded",
}),
},
});
await expect(service.getConnection(child.id)).resolves.toMatchObject({
healthStatus: "degraded",
healthMessage: expect.stringContaining("INACTIVE"),
});
expect(client.listConnectedAccounts).toHaveBeenCalledWith(
expect.objectContaining({
toolkitSlugs: ["github"],
}),
);
it("retires saved Composio broker and child records without touching credentials until removal", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(db, company.id);
const remoteHttpRequest = vi.fn();
const service = createTestToolAccessService(db, { remoteHttpRequest });
for (const connection of [parent, child]) {
await expect(service.getConnection(connection.id)).resolves.toMatchObject({
enabled: false, healthStatus: "error", healthMessage: expect.stringContaining("Add a new Composio MCP connection"),
});
await expect(service.checkHealth(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
await expect(service.refreshCatalog(connection.id)).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
await expect(service.reconnectGalleryApp(connection.id, company.id, { credentialValues: {} })).rejects.toMatchObject({ status: 422 });
await expect(service.connectGalleryApp(company.id, { galleryKey: "composio", connectionMethodKey: "mcp", reconnectConnectionId: connection.id })).rejects.toMatchObject({ status: 422, details: { code: "composio_broker_retired" } });
}
expect(remoteHttpRequest).not.toHaveBeenCalled();
const [savedParent] = await db.select().from(toolConnections).where(eq(toolConnections.id, parent.id));
expect(savedParent.credentialSecretRefs).toEqual(parent.credentialSecretRefs);
// Each obsolete record can still be explicitly removed with ordinary cleanup.
for (const connection of [child, parent]) {
await expect(service.archiveConnection(connection.id, company.id)).resolves.toMatchObject({ connection: { status: "archived", enabled: false, credentialSecretRefs: [] } });
}
});
it("cascades Composio parent pause, restores active children, and keeps inactive children disabled", async () => {
it("rejects old Composio API-key setup and removes toolkit-management routes", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(
db,
company.id,
);
let accountStatus = "ACTIVE";
const service = createTestToolAccessService(db, {
composioClientFactory: () => fakeComposioClient(() => accountStatus),
});
await service.updateConnection(parent.id, { enabled: false });
await expect(service.getConnection(child.id)).resolves.toMatchObject({
enabled: false,
config: expect.objectContaining({ disabledByComposioParent: true }),
});
accountStatus = "INACTIVE";
await service.updateConnection(parent.id, { enabled: true });
await expect(service.getConnection(child.id)).resolves.toMatchObject({
enabled: false,
healthStatus: "degraded",
healthMessage: expect.stringContaining("INACTIVE"),
});
accountStatus = "ACTIVE";
await service.updateConnection(parent.id, { enabled: true });
const restored = await service.getConnection(child.id);
expect(restored).toMatchObject({
enabled: true,
healthStatus: "unchecked",
healthMessage: null,
});
expect(restored.config).not.toHaveProperty("disabledByComposioParent");
});
it("requires child-removal confirmation before deleting a Composio parent", async () => {
const company = await createCompany(db);
const { parent, child } = await createComposioParentAndChild(
db,
company.id,
);
const service = createTestToolAccessService(db);
await expect(
service.archiveConnection(parent.id, company.id),
).rejects.toMatchObject({
status: 409,
details: {
code: "composio_child_removal_confirmation_required",
childConnectionCount: 1,
},
});
await expect(
service.archiveConnection(parent.id, company.id, undefined, {
confirmComposioChildren: true,
}),
).resolves.toMatchObject({
connection: expect.objectContaining({ status: "archived" }),
});
await expect(service.getConnection(child.id)).resolves.toMatchObject({
status: "archived",
enabled: false,
});
await expect(service.connectGalleryApp(company.id, {
galleryKey: "composio", connectionMethodKey: "api-key", credentialValues: { "credentials.apiKey": "obsolete" },
})).rejects.toMatchObject({ status: 422 });
const app = createRouteApp(db, boardSessionActor(company.id, "admin"));
const id = randomUUID();
await request(app).get(`/api/tool-connections/${id}/services`).expect(404);
await request(app).post(`/api/tool-connections/${id}/services/github/connect`).send({}).expect(404);
await request(app).get(`/api/tool-connections/${id}/services/github/status`).expect(404);
await request(app).delete(`/api/tool-connections/${id}/services/github`).expect(404);
});
it("returns server-derived create capabilities for a non-manager member", async () => {
+12 -130
View File
@@ -53,7 +53,6 @@ import {
summarizeToolValue,
} from "../services/tool-content-guards.js";
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
import type { ComposioClient } from "../services/composio.js";
import { secretService } from "../services/secrets.js";
import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js";
import {
@@ -1464,141 +1463,24 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
expect(otherTools.map((tool) => tool.catalogEntryId)).not.toContain(remoteTool.catalogEntry.id);
});
it("invokes an installed Composio child through a tool-scoped session and re-mints once on 401", async () => {
it("hides retired Composio child tools and denies a previously discovered tool without upstream traffic", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const remoteTool = await createRemoteMcpTool(db, company.id, {
applicationKey: "composio",
connectionName: "GitHub (via Composio)",
toolName: "GITHUB_LIST_REPOS",
title: "List repositories",
riskLevel: "read",
});
const apiKey = await secretService(db).create(company.id, {
name: "Composio API key",
key: `tool_app.${randomUUID()}.composio_api_key`,
provider: "local_encrypted",
value: "ak_composio_gateway_fixture",
});
const [parent] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: remoteTool.application.id,
name: "Composio",
uid: `composio/${randomUUID()}`,
transport: "rest_api",
authKind: "api_key",
status: "active",
enabled: true,
config: { sourceTemplateKey: "composio" },
transportConfig: { sourceTemplateKey: "composio" },
credentialRefs: [{
name: "credentials.apiKey",
secretId: apiKey.id,
version: "latest",
placement: "header",
key: "x-api-key",
prefix: null,
}],
credentialSecretRefs: [{
secretId: apiKey.id,
versionSelector: "latest",
configPath: "credentials.apiKey",
required: true,
label: "Composio API key",
}],
}).returning();
await db.insert(companySecretBindings).values({
companyId: company.id,
secretId: apiKey.id,
targetType: "tool_connection",
targetId: parent!.id,
configPath: "credentials.apiKey",
});
const childConfig = {
provider: "composio",
parentConnectionId: parent!.id,
toolkitSlug: "github",
connectedAccountId: "ca_github_fixture",
};
await db.update(toolConnections).set({ config: childConfig, transportConfig: childConfig })
.where(eq(toolConnections.id, remoteTool.connection.id));
await db.insert(toolConnectionInstalls).values({
companyId: company.id,
connectionId: remoteTool.connection.id,
targetType: "agent",
targetId: agent.id,
});
const remote = await createRemoteMcpTool(db, company.id, { applicationKey: "composio", connectionName: "Old toolkit", toolName: "read", title: "Read" });
const profile = await allowToolsForAgent(db, company.id, agent.id, []);
await db.insert(toolProfileEntries).values({
companyId: company.id,
profileId: profile.id,
selectorType: "catalog_entry",
effect: "include",
catalogEntryId: remoteTool.catalogEntry.id,
});
const sessionRequests: Array<{ apiKey: string; userId: string; options: unknown }> = [];
let upstreamCalls = 0;
const gateway = createTestToolGatewayService(db, {
composioClientFactory: (resolvedApiKey) => ({
createSession: async (userId, sessionOptions) => {
sessionRequests.push({ apiKey: resolvedApiKey, userId, options: sessionOptions });
const suffix = sessionRequests.length;
return {
session_id: `composio-session-${suffix}`,
mcp: {
url: `https://mcp.composio.test/session-${suffix}`,
headers: { Authorization: `Bearer composio-session-token-${suffix}` },
},
};
},
}) as unknown as ComposioClient,
remoteHttpRequest: async (url, init) => {
upstreamCalls += 1;
expect(url).toBe(`https://mcp.composio.test/session-${upstreamCalls}`);
expect(new Headers(init.headers).get("authorization")).toBe(`Bearer composio-session-token-${upstreamCalls}`);
if (upstreamCalls === 1) return new Response("unauthorized", { status: 401 });
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: JSON.parse(String(init.body)).id,
result: { content: [{ type: "text", text: "repo-a" }], structuredContent: { repositories: ["repo-a"] } },
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", catalogEntryId: remote.catalogEntry.id });
const remoteHttpRequest = vi.fn();
const gateway = createTestToolGatewayService(db, { remoteHttpRequest });
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find((candidate) => candidate.connectionId === remoteTool.connection.id);
const listed = await gateway.listToolsForSession(session.token);
const tool = listed.find((entry) => entry.connectionId === remote.connection.id)!;
expect(tool).toBeDefined();
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({
status: "completed",
result: { content: "repo-a", data: { structuredContent: { repositories: ["repo-a"] } } },
});
await db.update(connectionGrants).set({ updatedAt: new Date(Date.now() + 1_000) })
.where(eq(connectionGrants.connectionId, remoteTool.connection.id));
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {} })).resolves.toMatchObject({
status: "completed",
result: { content: "repo-a" },
});
expect(sessionRequests).toEqual([
expect.objectContaining({
apiKey: "ak_composio_gateway_fixture",
userId: `paperclip:${company.id}`,
options: expect.objectContaining({
mcp: true,
toolkits: ["github"],
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
}),
}),
expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }),
expect.objectContaining({ options: expect.objectContaining({ tools: { github: { enable: ["GITHUB_LIST_REPOS"] } } }) }),
]);
const [persistedChild] = await db.select().from(toolConnections).where(eq(toolConnections.id, remoteTool.connection.id));
expect(JSON.stringify(persistedChild!.config)).not.toContain("session-");
expect(JSON.stringify(persistedChild!.transportConfig)).not.toContain("mcp.composio.test");
expect(persistedChild!.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([
expect.stringMatching(/^composio\.session\.[a-f0-9]+\.url$/),
expect.stringMatching(/^composio\.session\.[a-f0-9]+\.header\.[a-f0-9]+$/),
]));
await db.update(toolConnections).set({ config: { provider: "composio", parentConnectionId: randomUUID(), toolkitSlug: "github", url: "https://legacy.example/mcp" } }).where(eq(toolConnections.id, remote.connection.id));
expect((await gateway.listToolsForSession(session.token)).some((entry) => entry.connectionId === remote.connection.id)).toBe(false);
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).rejects.toThrow();
expect(remoteHttpRequest).not.toHaveBeenCalled();
});
it("lists and executes connected local stdio MCP catalog tools through the gateway", async () => {
-28
View File
@@ -10351,13 +10351,6 @@ registerCurrentRoute({
summary: "Replace the member audience of a tool connection grant",
});
registerCurrentRoute({
method: "get",
path: "/api/tool-connections/{connectionId}/services",
tags: ["tool-access"],
summary: "List the broker services behind a tool connection",
});
registerCurrentRoute({
method: "post",
path: "/api/tool-connections/{connectionId}/railway/ssh",
@@ -10375,27 +10368,6 @@ registerCurrentRoute({
},
});
registerCurrentRoute({
method: "post",
path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/connect",
tags: ["tool-access"],
summary: "Start a broker service connection for a toolkit",
});
registerCurrentRoute({
method: "get",
path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/status",
tags: ["tool-access"],
summary: "Poll the connection status of a broker service",
});
registerCurrentRoute({
method: "delete",
path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}",
tags: ["tool-access"],
summary: "Disconnect a broker service from a tool connection",
});
registerCurrentRoute({
method: "get",
path: "/api/tool-connections/{connectionId}/installs",
-55
View File
@@ -58,7 +58,6 @@ import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../er
import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js";
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
import { RailwayError } from "../services/railway.js";
import type { ComposioClient } from "../services/composio.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import {
appWithPaperclipCloudConnectorAvailability,
@@ -240,7 +239,6 @@ export function toolAccessRoutes(
/** Test-only seams forwarded to the tool access service. */
remoteHttpEndpointLookup?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpEndpointLookup"];
remoteHttpRequest?: NonNullable<Parameters<typeof toolAccessService>[1]>["remoteHttpRequest"];
composioClientFactory?: (apiKey: string) => ComposioClient;
vercelConnectClient?: VercelConnectClient | null;
paperclipCloudConnector?: PaperclipCloudConnector | null;
connectionIntentHeartbeat?: Pick<Heartbeat, "wakeup">;
@@ -1677,13 +1675,6 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(connection);
});
router.get("/tool-connections/:connectionId/services", async (req, res) => {
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
res.json(await svc.listComposioServices(connection.id, getActorInfo(req)));
});
router.post("/tool-connections/:connectionId/railway/ssh", validate(configureRailwaySshSchema), async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
@@ -1697,51 +1688,6 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json(setup);
});
router.post("/tool-connections/:connectionId/services/:toolkitSlug/connect", async (req, res) => {
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
const body = req.body && typeof req.body === "object" ? req.body as Record<string, unknown> : {};
const result = await svc.startComposioServiceConnect(connection.id, req.params.toolkitSlug as string, {
...(typeof body.authConfigId === "string" ? { authConfigId: body.authConfigId } : {}),
...(typeof body.callbackUrl === "string" ? { callbackUrl: body.callbackUrl } : {}),
});
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "composio.service_connect_started",
entityType: "tool_connection",
entityId: connection.id,
details: { toolkitSlug: req.params.toolkitSlug, authConfigId: result.authConfigId },
});
res.status(201).json(result);
});
router.get("/tool-connections/:connectionId/services/:toolkitSlug/status", async (req, res) => {
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
res.json(await svc.pollComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req)));
});
router.delete("/tool-connections/:connectionId/services/:toolkitSlug", async (req, res) => {
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertToolConnectionConfigureAccess(req, connection);
const result = await svc.disconnectComposioService(connection.id, req.params.toolkitSlug as string, getActorInfo(req));
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "composio.service_disconnected",
entityType: "tool_connection",
entityId: connection.id,
details: { toolkitSlug: req.params.toolkitSlug, removedChildCount: result.removedChildIds.length },
});
res.json(result);
});
router.get("/tool-connections/:connectionId/grants", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
@@ -2155,7 +2101,6 @@ function connectorEnrollmentPrincipal(req: Request): string {
existing.id,
existing.companyId,
getActorInfo(req),
{ confirmComposioChildren: req.query.confirmComposioChildren === "true" },
);
const applicationAfter = await svc.getApplication(existing.applicationId);
// The receipt is counts and outcomes only. Removal is a revocation boundary
@@ -1,263 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecretBindings, toolConnections } from "@paperclipai/db";
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { createComposioClient, type ComposioClient } from "./composio.js";
import { secretService } from "./secrets.js";
const DEFAULT_SESSION_TTL_MS = 50 * 60 * 1000;
const sessionQueues = new Map<string, Promise<void>>();
type ComposioChildConfig = {
parentConnectionId: string;
toolkitSlug: string;
connectedAccountId?: string;
};
type SessionRef = { name: string; configPath: string; secretId: string };
type CachedSession = {
sessionId: string;
scopeKey: string;
fingerprint: string;
createdAt: string;
urlRef: SessionRef;
headerRefs: SessionRef[];
};
export type ComposioSessionCredentials = {
sessionId: string;
scopeKey: string;
url: string;
headers: Record<string, string>;
};
export type ComposioSessionManagerOptions = {
composioClientFactory?: (apiKey: string) => ComposioClient;
now?: () => Date;
sessionTtlMs?: number;
};
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function composioChildConfig(connection: typeof toolConnections.$inferSelect): ComposioChildConfig | null {
const config = record(connection.config);
if (config.provider !== "composio") return null;
if (typeof config.parentConnectionId !== "string" || typeof config.toolkitSlug !== "string") return null;
return {
parentConnectionId: config.parentConnectionId,
toolkitSlug: config.toolkitSlug,
...(typeof config.connectedAccountId === "string" ? { connectedAccountId: config.connectedAccountId } : {}),
};
}
function normalizedTools(tools: string[] | undefined): string[] {
return [...new Set((tools ?? []).map((tool) => tool.trim()).filter(Boolean))].sort();
}
function scopeKeyFor(toolkitSlug: string, tools: string[]): string {
return createHash("sha256").update(JSON.stringify({ toolkitSlug, tools })).digest("hex").slice(0, 24);
}
function cacheFrom(connection: typeof toolConnections.$inferSelect): Record<string, CachedSession> {
return record(record(connection.transportConfig).composioSessions) as Record<string, CachedSession>;
}
export function createComposioSessionManager(db: Db, options: ComposioSessionManagerOptions = {}) {
const secrets = secretService(db);
const now = options.now ?? (() => new Date());
const ttlMs = Math.max(1, options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS);
async function connectionRow(connectionId: string) {
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)).limit(1);
if (!connection) throw unprocessable("The Composio toolkit connection no longer exists.", { code: "composio_child_missing" });
return connection;
}
async function parentApiKey(child: typeof toolConnections.$inferSelect, config: ComposioChildConfig) {
const [parent] = await db.select().from(toolConnections).where(and(
eq(toolConnections.id, config.parentConnectionId),
eq(toolConnections.companyId, child.companyId),
)).limit(1);
if (!parent) throw unprocessable("The parent Composio connection is missing.", { code: "composio_parent_missing" });
const headerRef = parent.credentialRefs.find((ref) => ref.placement === "header" && ref.key.toLowerCase() === "x-api-key");
const secretRef = parent.credentialSecretRefs.find((ref) => ref.configPath === "credentials.apiKey")
?? (headerRef
? {
secretId: headerRef.secretId,
versionSelector: headerRef.version ?? "latest",
configPath: headerRef.name.startsWith("credentials.") ? headerRef.name : `credentials.${headerRef.name}`,
}
: undefined);
if (!secretRef) throw unprocessable("The parent Composio API key is missing.", { code: "composio_api_key_missing" });
const apiKey = await secrets.resolveSecretValue(parent.companyId, secretRef.secretId, secretRef.versionSelector ?? "latest", {
consumerType: "tool_connection",
consumerId: parent.id,
configPath: secretRef.configPath,
actorType: "system",
});
const revision = createHash("sha256").update(JSON.stringify({
parentId: parent.id,
updatedAt: parent.updatedAt.toISOString(),
secretId: secretRef.secretId,
versionSelector: secretRef.versionSelector ?? "latest",
})).digest("hex");
return { apiKey, revision };
}
async function resolveRef(child: typeof toolConnections.$inferSelect, ref: SessionRef) {
return secrets.resolveSecretValue(child.companyId, ref.secretId, "latest", {
consumerType: "tool_connection",
consumerId: child.id,
configPath: ref.configPath,
actorType: "system",
});
}
async function resolveCached(child: typeof toolConnections.$inferSelect, cached: CachedSession): Promise<ComposioSessionCredentials> {
const url = await resolveRef(child, cached.urlRef);
const headers = Object.fromEntries(await Promise.all(cached.headerRefs.map(async (ref) => [ref.name, await resolveRef(child, ref)])));
return { sessionId: cached.sessionId, scopeKey: cached.scopeKey, url, headers };
}
async function createOrRotateRef(input: {
child: typeof toolConnections.$inferSelect;
cached?: SessionRef;
name: string;
configPath: string;
value: string;
}): Promise<SessionRef> {
if (input.cached) {
await secrets.rotate(input.cached.secretId, { value: input.value });
return { ...input.cached, name: input.name };
}
const secret = await secrets.create(input.child.companyId, {
name: `${input.child.name} Composio session ${input.name} ${randomUUID().slice(0, 8)}`,
key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
provider: "local_encrypted",
value: input.value,
description: `Hosted Composio MCP session credential for ${input.child.name}.`,
});
return { name: input.name, configPath: input.configPath, secretId: secret.id };
}
async function mint(
connectionId: string,
tools: string[],
force: boolean,
scopeRevision?: string,
): Promise<ComposioSessionCredentials> {
const child = await connectionRow(connectionId);
const config = composioChildConfig(child);
if (!config) throw unprocessable("This connection is not a Composio toolkit child.", { code: "not_composio_child" });
const { apiKey, revision } = await parentApiKey(child, config);
const scopeKey = scopeKeyFor(config.toolkitSlug, tools);
const fingerprint = createHash("sha256").update(JSON.stringify({
revision,
toolkitSlug: config.toolkitSlug,
connectedAccountId: config.connectedAccountId ?? null,
tools,
scopeRevision: scopeRevision ?? null,
})).digest("hex");
const cache = cacheFrom(child);
const cached = cache[scopeKey];
if (!force && cached?.fingerprint === fingerprint && Date.parse(cached.createdAt) + ttlMs > now().getTime()) {
return resolveCached(child, cached);
}
const client = options.composioClientFactory?.(apiKey) ?? createComposioClient({ apiKey });
const session = await client.createSession(`paperclip:${child.companyId}`, {
mcp: true,
toolkits: [config.toolkitSlug],
...(tools.length > 0 ? { tools: { [config.toolkitSlug]: { enable: tools } } } : {}),
...(config.connectedAccountId ? { connectedAccounts: { [config.toolkitSlug]: [config.connectedAccountId] } } : {}),
});
if (!session.mcp?.url) throw unprocessable("Composio did not return a hosted MCP URL.", { code: "composio_session_invalid" });
const prefix = `composio.session.${scopeKey}`;
const urlRef = await createOrRotateRef({
child,
cached: cached?.urlRef,
name: "url",
configPath: `${prefix}.url`,
value: session.mcp.url,
});
const priorHeaders = new Map((cached?.headerRefs ?? []).map((ref) => [ref.name.toLowerCase(), ref]));
const headerRefs: SessionRef[] = [];
for (const [name, value] of Object.entries(session.mcp.headers ?? {})) {
headerRefs.push(await createOrRotateRef({
child,
cached: priorHeaders.get(name.toLowerCase()),
name,
configPath: `${prefix}.header.${createHash("sha256").update(name.toLowerCase()).digest("hex").slice(0, 16)}`,
value,
}));
}
const nextCached: CachedSession = {
sessionId: session.session_id,
scopeKey,
fingerprint,
createdAt: now().toISOString(),
urlRef,
headerRefs,
};
const nextCache = { ...cache, [scopeKey]: nextCached };
const refsByPath = new Map(child.credentialSecretRefs.map((ref) => [ref.configPath, ref]));
for (const ref of [urlRef, ...headerRefs]) {
refsByPath.set(ref.configPath, {
secretId: ref.secretId,
versionSelector: "latest",
configPath: ref.configPath,
required: true,
label: ref.name === "url" ? "Composio MCP session URL" : `Composio MCP ${ref.name} header`,
keyScope: scopeKey,
} satisfies ToolCredentialSecretRef);
}
const credentialSecretRefs = [...refsByPath.values()];
const updated = await db.transaction(async (tx) => {
const [row] = await tx.update(toolConnections).set({
transportConfig: { ...record(child.transportConfig), composioSessions: nextCache },
credentialSecretRefs,
updatedAt: now(),
}).where(eq(toolConnections.id, child.id)).returning();
await tx.delete(companySecretBindings).where(and(
eq(companySecretBindings.companyId, child.companyId),
eq(companySecretBindings.targetType, "tool_connection"),
eq(companySecretBindings.targetId, child.id),
));
if (credentialSecretRefs.length > 0) {
await tx.insert(companySecretBindings).values(credentialSecretRefs.map((ref) => ({
companyId: child.companyId,
secretId: ref.secretId,
targetType: "tool_connection" as const,
targetId: child.id,
configPath: ref.configPath,
projectionClass: ref.projectionClass ?? "unclassified",
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
})));
}
return row;
});
return resolveCached(updated, nextCached);
}
return {
ensureSession(connectionId: string, input: { tools?: string[]; force?: boolean; scopeRevision?: string } = {}) {
const tools = normalizedTools(input.tools);
const previous = sessionQueues.get(connectionId) ?? Promise.resolve();
const pending = previous.catch(() => undefined).then(
() => mint(connectionId, tools, input.force === true, input.scopeRevision),
);
const queued = pending.then(() => undefined, () => undefined);
sessionQueues.set(connectionId, queued);
return pending.finally(() => {
if (sessionQueues.get(connectionId) === queued) sessionQueues.delete(connectionId);
});
},
};
}
-198
View File
@@ -1,198 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { ComposioApiError, createComposioClient } from "./composio.js";
type Fixture = {
baseUrl: string;
requests: Array<{ method: string; url: string; apiKey: string | undefined }>;
close(): Promise<void>;
};
async function startFixture(
handle: (request: IncomingMessage, response: ServerResponse) => void,
): Promise<Fixture> {
const requests: Fixture["requests"] = [];
const server = createServer((request, response) => {
requests.push({
method: request.method ?? "GET",
url: request.url ?? "/",
apiKey: Array.isArray(request.headers["x-api-key"])
? request.headers["x-api-key"]?.[0]
: request.headers["x-api-key"],
});
handle(request, response);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture did not bind a TCP port");
return {
baseUrl: `http://127.0.0.1:${address.port}/api/v3.1`,
requests,
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
const fixtures: Fixture[] = [];
afterEach(async () => {
await Promise.all(fixtures.splice(0).map((fixture) => fixture.close()));
});
describe("Composio REST client", () => {
it("serializes every supported REST operation", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const fetchMock = (async (input: string | URL | Request, init?: RequestInit) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
return new Response(JSON.stringify({ items: [], session_id: "session-1", mcp: { url: "https://mcp.test" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const client = createComposioClient({
apiKey: "ak_fixture",
baseUrl: "https://composio.test/api/v3.1",
fetch: fetchMock,
});
await client.listAuthConfigs({
cursor: "auth-page",
limit: 25,
toolkitSlugs: ["github", "slack"],
showDisabled: true,
});
await client.createConnectLink({
authConfigId: "ac_1",
userId: "user-1",
alias: "primary",
callbackUrl: "https://paperclip.test/callback",
});
await client.listConnectedAccounts({
cursor: "account-page",
limit: 10,
toolkitSlugs: ["github", "slack"],
statuses: ["ACTIVE", "EXPIRED"],
userIds: ["user-1", "user-2"],
authConfigIds: ["ac_1", "ac_2"],
});
await client.deleteConnectedAccount("ca/with spaces");
await client.createSession("user-1", {
mcp: true,
toolkits: ["github"],
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
authConfigs: { github: "ac_1" },
connectedAccounts: { github: ["ca_1"] },
});
await client.resumeSession("session/with spaces", { mcp: true });
expect(requests).toEqual([
{
url: "https://composio.test/api/v3.1/auth_configs?cursor=auth-page&limit=25&show_disabled=true&toolkit_slug=github%2Cslack",
method: "GET",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/connected_accounts/link",
method: "POST",
body: {
auth_config_id: "ac_1",
user_id: "user-1",
alias: "primary",
callback_url: "https://paperclip.test/callback",
},
},
{
url: "https://composio.test/api/v3.1/connected_accounts?cursor=account-page&limit=10&toolkit_slugs=github&toolkit_slugs=slack&statuses=ACTIVE&statuses=EXPIRED&user_ids=user-1&user_ids=user-2&auth_config_ids=ac_1&auth_config_ids=ac_2",
method: "GET",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/connected_accounts/ca%2Fwith%20spaces",
method: "DELETE",
body: undefined,
},
{
url: "https://composio.test/api/v3.1/tool_router/session",
method: "POST",
body: {
user_id: "user-1",
mcp: true,
toolkits: { enabled: ["github"] },
tools: { github: { enable: ["GITHUB_LIST_REPOS"] } },
auth_configs: { github: "ac_1" },
connected_accounts: { github: ["ca_1"] },
},
},
{
url: "https://composio.test/api/v3.1/tool_router/session/session%2Fwith%20spaces/attach",
method: "POST",
body: {},
},
]);
});
it("validates an API key with the cheap toolkit-list call", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ items: [] }));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
await expect(client.validateApiKey()).resolves.toBeUndefined();
expect(fixture.requests).toEqual([{
method: "GET",
url: "/api/v3.1/toolkits?limit=1",
apiKey: "ak_fixture",
}]);
});
it("rejects an invalid key without reflecting the provider response body", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ message: "provider-controlled secret detail" }));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "bad_fixture", baseUrl: fixture.baseUrl });
const error = await client.validateApiKey().catch((caught) => caught);
expect(error).toBeInstanceOf(ComposioApiError);
expect(error).toMatchObject({ status: 401, message: "Composio rejected the API key." });
expect(String(error)).not.toContain("provider-controlled");
});
it("lists typed toolkits from the project behind the key", async () => {
const fixture = await startFixture((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
items: [{
slug: "github",
name: "GitHub",
auth_schemes: ["oauth2"],
meta: { tools_count: 42, logo: "https://example.test/github.png" },
}],
next_cursor: "next-page",
}));
});
fixtures.push(fixture);
const client = createComposioClient({ apiKey: "ak_fixture", baseUrl: fixture.baseUrl });
const result = await client.listToolkits({ limit: 10, cursor: "page-1" });
expect(result).toEqual({
items: [{
slug: "github",
name: "GitHub",
auth_schemes: ["oauth2"],
meta: { tools_count: 42, logo: "https://example.test/github.png" },
}],
next_cursor: "next-page",
});
expect(fixture.requests[0]?.url).toBe("/api/v3.1/toolkits?cursor=page-1&limit=10");
});
});
-216
View File
@@ -1,216 +0,0 @@
const DEFAULT_COMPOSIO_API_BASE_URL = "https://backend.composio.dev/api/v3.1";
export class ComposioApiError extends Error {
constructor(message: string, readonly status: number) {
super(message);
this.name = "ComposioApiError";
}
}
export type ComposioPage<T> = {
items: T[];
next_cursor?: string | null;
total_pages?: number;
current_page?: number;
total_items?: number;
};
export type ComposioToolkit = {
slug: string;
name: string;
type?: string;
auth_schemes?: string[];
composio_managed_auth_schemes?: string[];
no_auth?: boolean;
auth_guide_url?: string | null;
meta?: {
description?: string | null;
logo?: string | null;
app_url?: string | null;
tools_count?: number;
triggers_count?: number;
version?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ComposioAuthConfig = {
id: string;
uuid?: string;
name?: string;
auth_scheme: string;
is_composio_managed: boolean;
status?: string;
toolkit: { slug: string; logo?: string | null; auth_guide_url?: string | null; auth_hint_url?: string | null };
[key: string]: unknown;
};
export type ComposioConnectLink = {
link_token: string;
redirect_url: string;
expires_at: string;
connected_account_id?: string | null;
[key: string]: unknown;
};
export type ComposioConnectedAccount = {
id: string;
user_id: string;
status: string;
status_reason?: string | null;
is_disabled?: boolean;
toolkit: { slug: string };
auth_config: { id: string; auth_scheme: string; is_composio_managed: boolean; is_disabled?: boolean };
[key: string]: unknown;
};
export type ComposioSession = {
session_id: string;
mcp: { type?: string; url: string; headers?: Record<string, string> };
config?: Record<string, unknown>;
config_version?: number;
warnings?: Array<{ code: string; message: string }>;
[key: string]: unknown;
};
export type ComposioListOptions = { cursor?: string; limit?: number };
export type ComposioSessionOptions = {
mcp: true;
toolkits?: string[];
tools?: Record<string, { enable: string[] }>;
authConfigs?: Record<string, string>;
connectedAccounts?: Record<string, string[]>;
};
export interface ComposioClient {
validateApiKey(): Promise<void>;
listToolkits(options?: ComposioListOptions): Promise<ComposioPage<ComposioToolkit>>;
listAuthConfigs(options?: ComposioListOptions & { toolkitSlugs?: string[]; showDisabled?: boolean }): Promise<ComposioPage<ComposioAuthConfig>>;
createConnectLink(input: { authConfigId: string; userId: string; alias?: string; callbackUrl?: string }): Promise<ComposioConnectLink>;
listConnectedAccounts(options?: ComposioListOptions & { toolkitSlugs?: string[]; statuses?: string[]; userIds?: string[]; authConfigIds?: string[] }): Promise<ComposioPage<ComposioConnectedAccount>>;
deleteConnectedAccount(connectedAccountId: string): Promise<void>;
createSession(userId: string, options: ComposioSessionOptions): Promise<ComposioSession>;
resumeSession(sessionId: string, options: { mcp: true }): Promise<ComposioSession>;
}
type ComposioClientOptions = { apiKey: string; baseUrl?: string; fetch?: typeof globalThis.fetch };
function appendQuery(url: URL, key: string, value: string | number | boolean | undefined) {
if (value !== undefined) url.searchParams.append(key, String(value));
}
function appendQueryList(url: URL, key: string, values: string[] | undefined) {
for (const value of values ?? []) url.searchParams.append(key, value);
}
function errorMessage(status: number): string {
if (status === 401 || status === 403) return "Composio rejected the API key.";
if (status === 429) return "Composio rate-limited the request. Try again shortly.";
return `Composio request failed with HTTP ${status}.`;
}
export function createComposioClient(options: ComposioClientOptions): ComposioClient {
const apiKey = options.apiKey.trim();
if (!apiKey) throw new Error("A Composio API key is required.");
const baseUrl = (options.baseUrl ?? DEFAULT_COMPOSIO_API_BASE_URL).replace(/\/+$/, "");
const fetchImpl = options.fetch ?? globalThis.fetch;
async function request<T>(path: string, init: RequestInit = {}, query?: (url: URL) => void): Promise<T> {
const url = new URL(`${baseUrl}/${path.replace(/^\/+/, "")}`);
query?.(url);
let response: Response;
try {
response = await fetchImpl(url, {
...init,
headers: {
accept: "application/json",
"x-api-key": apiKey,
...(init.body ? { "content-type": "application/json" } : {}),
...init.headers,
},
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
try {
return await response.json() as T;
} catch {
throw new ComposioApiError("Composio returned an invalid response.", response.status);
}
}
return {
async validateApiKey() {
await this.listToolkits({ limit: 1 });
},
listToolkits(options = {}) {
return request<ComposioPage<ComposioToolkit>>("toolkits", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
});
},
listAuthConfigs(options = {}) {
return request<ComposioPage<ComposioAuthConfig>>("auth_configs", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQuery(url, "show_disabled", options.showDisabled);
appendQuery(url, "toolkit_slug", options.toolkitSlugs?.join(","));
});
},
createConnectLink(input) {
return request<ComposioConnectLink>("connected_accounts/link", {
method: "POST",
body: JSON.stringify({
auth_config_id: input.authConfigId,
user_id: input.userId,
...(input.alias ? { alias: input.alias } : {}),
...(input.callbackUrl ? { callback_url: input.callbackUrl } : {}),
}),
});
},
listConnectedAccounts(options = {}) {
return request<ComposioPage<ComposioConnectedAccount>>("connected_accounts", {}, (url) => {
appendQuery(url, "cursor", options.cursor);
appendQuery(url, "limit", options.limit);
appendQueryList(url, "toolkit_slugs", options.toolkitSlugs);
appendQueryList(url, "statuses", options.statuses);
appendQueryList(url, "user_ids", options.userIds);
appendQueryList(url, "auth_config_ids", options.authConfigIds);
});
},
async deleteConnectedAccount(connectedAccountId) {
const url = new URL(`${baseUrl}/connected_accounts/${encodeURIComponent(connectedAccountId)}`);
let response: Response;
try {
response = await fetchImpl(url, {
method: "DELETE",
headers: { accept: "application/json", "x-api-key": apiKey },
});
} catch {
throw new ComposioApiError("Could not reach Composio.", 0);
}
if (!response.ok) throw new ComposioApiError(errorMessage(response.status), response.status);
},
createSession(userId, options) {
return request<ComposioSession>("tool_router/session", {
method: "POST",
body: JSON.stringify({
user_id: userId,
mcp: options.mcp,
...(options.toolkits ? { toolkits: { enabled: options.toolkits } } : {}),
...(options.tools ? { tools: options.tools } : {}),
...(options.authConfigs ? { auth_configs: options.authConfigs } : {}),
...(options.connectedAccounts ? { connected_accounts: options.connectedAccounts } : {}),
}),
});
},
resumeSession(sessionId, _options) {
return request<ComposioSession>(`tool_router/session/${encodeURIComponent(sessionId)}/attach`, {
method: "POST",
body: JSON.stringify({}),
});
},
};
}
-1
View File
@@ -98,7 +98,6 @@ export {
VercelConnectClientError,
type VercelConnectClient,
} from "./vercel-connect.js";
export { createComposioClient, ComposioApiError, type ComposioClient } from "./composio.js";
export { smokeLabService } from "./smoke-lab.js";
export { backfillLegacyToolOAuthTokens } from "./tool-oauth-legacy-backfill.js";
export { toolAccessPolicyService } from "./tool-access-policy.js";
+28 -574
View File
@@ -232,15 +232,7 @@ import {
ToolRuntimeSupervisorError,
} from "./tool-runtime-supervisor.js";
import { listConnectionLifecycleEvents } from "./tool-connection-activity.js";
import {
ComposioApiError,
createComposioClient,
type ComposioClient,
} from "./composio.js";
import {
composioChildConfig,
createComposioSessionManager,
} from "./composio-session-manager.js";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
import {
appWithPaperclipCloudConnectorAvailability,
paperclipCloudConnectorCapabilitiesFromEnv,
@@ -648,8 +640,6 @@ type ToolAccessServiceOptions = {
remoteHttpEndpointLookup?: RemoteHttpEndpointLookup;
/** Test seam for protocol fixtures. Production uses the DNS-pinned transport. */
remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response>;
/** Test seam for Composio without live vendor traffic. */
composioClientFactory?: (apiKey: string) => ComposioClient;
/** Test seam for the centrally registered Gmail OAuth broker. */
paperclipCloudConnector?: PaperclipCloudConnector | null;
/** @deprecated Use paperclipCloudConnector. */
@@ -922,7 +912,6 @@ const APPROVED_STDIO_TEMPLATES: Record<
};
const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets";
const COMPOSIO_GALLERY_KEY = "composio";
const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets";
const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV =
"GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS";
@@ -1590,6 +1579,7 @@ function assertClass3ToolCredentialRefAllowed(ref: {
function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection {
connectionPurposeTransportSchema.parse(row);
const retired = isRetiredComposioConnection(row);
return {
id: row.id,
companyId: row.companyId,
@@ -1605,13 +1595,13 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection
externalCredential: row.externalCredential ?? null,
credentialPolicy: row.credentialPolicy,
status: row.status,
enabled: row.enabled,
enabled: row.enabled && !retired,
config: row.config ?? {},
transportConfig: row.transportConfig ?? {},
credentialRefs: row.credentialRefs ?? [],
credentialSecretRefs: row.credentialSecretRefs ?? [],
healthStatus: row.healthStatus,
healthMessage: row.healthMessage,
healthStatus: retired ? "error" : row.healthStatus,
healthMessage: retired ? RETIRED_COMPOSIO_MESSAGE : row.healthMessage,
healthCheckedAt: row.healthCheckedAt,
lastHealthAt: row.lastHealthAt,
lastCatalogRefreshAt: row.lastCatalogRefreshAt,
@@ -2324,7 +2314,6 @@ function normalizedProviderToolName(toolName: string): string {
// Aggregators can add arbitrarily powerful tools without changing their MCP
// endpoint. Only these reviewed, exact capabilities are read-only. Unknown or
// renamed actions remain enabled by the usual access rules, but have write risk.
// Legacy Composio child connections do not use these gallery template keys.
const AGGREGATOR_READ_TOOLS = new Map<string, ReadonlySet<string>>([
["executor", new Set(["skills"])],
["composio", new Set(["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_GET_TOOL_SCHEMAS", "COMPOSIO_SEARCH_SKILLS", "COMPOSIO_USE_SKILL"])],
@@ -2814,7 +2803,7 @@ function healthFailureHttpStatus(failure: {
}): number {
if (failure.status === "missing_secret") return 422;
if (failure.code === "user_authorization_required") return 422;
if (failure.code === "composio_api_key_rejected") return 422;
if (failure.code === "composio_broker_retired") return 422;
if (failure.code === "tool_connection_transport_unsupported") return 422;
if (failure.code.endsWith("_endpoint_rejected")) return 422;
return 502;
@@ -2832,16 +2821,6 @@ function sanitizeHttpFailure(error: unknown): {
message: string;
code: string;
} {
if (error instanceof ComposioApiError) {
return {
status: "error",
message: error.message,
code:
error.status === 401 || error.status === 403
? "composio_api_key_rejected"
: "composio_request_failed",
};
}
if (error instanceof HttpError) {
const code = asRecord(error.details).code;
if (code === "user_authorization_required") {
@@ -2850,8 +2829,8 @@ function sanitizeHttpFailure(error: unknown): {
if (code === "tool_connection_transport_unsupported") {
return { status: "error", message: error.message, code };
}
if (code === "composio_connected_account_inactive") {
return { status: "degraded", message: error.message, code };
if (code === "composio_broker_retired") {
return { status: "error", message: error.message, code };
}
if (typeof code === "string" && code.startsWith("remote_http_")) {
return { status: "error", message: error.message, code };
@@ -3020,10 +2999,6 @@ export function toolAccessService(
badRequest(message, { code }),
).toString();
}
const composioSessions = createComposioSessionManager(db, {
composioClientFactory: options.composioClientFactory,
now: options.now,
});
const policySvc = toolAccessPolicyService(db);
const now = options.now ?? (() => new Date());
const configuredCloudConnector =
@@ -6087,36 +6062,12 @@ export function toolAccessService(
connectionId: string,
companyId?: string,
actor?: ActorInfo,
removalOptions: { confirmComposioChildren?: boolean } = {},
): Promise<ToolConnectionRemovalResult> {
const connection = await getConnectionRow(connectionId, companyId);
forgetMcpHttpSessions(connection.id);
const now = new Date();
const binding = actorBinding(actor);
if (isComposioConnection(connection)) {
const children = (await existingComposioChildren(connection)).filter(
(child) => child.status !== "archived",
);
if (
children.length > 0 &&
removalOptions.confirmComposioChildren !== true
) {
throw conflict(
"Deleting this Composio connection also removes its connected services. Confirm child removal to continue.",
{
code: "composio_child_removal_confirmation_required",
childConnectionCount: children.length,
},
);
}
for (const child of children) {
await removeConnection(child.id, child.companyId, actor, {
confirmComposioChildren: true,
});
}
}
// Grants are read before they are revoked: a retried removal must still see
// the credential refs of a grant an earlier pass already marked revoked.
const grantRows = await db
@@ -6774,17 +6725,12 @@ export function toolAccessService(
credentialHeaders?: Record<string, string>,
actor?: ActorInfo,
): Promise<McpToolDescriptor[]> {
const composioChild = composioChildConfig(connection);
const composioSession = composioChild
? await composioSessions.ensureSession(connection.id)
: null;
let headers = composioSession?.headers ??
credentialHeaders ?? {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(connection, actor)),
};
const endpoint =
composioSession?.url ?? (await resolvedRemoteEndpoint(connection, actor));
assertSupportedConnection(connection);
let headers = credentialHeaders ?? {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(connection, actor)),
};
const endpoint = await resolvedRemoteEndpoint(connection, actor);
// Pinned to the address the guard approved: `config.url` is operator-supplied,
// so a second DNS resolution here would reopen the rebinding window that
// PAP-17098 closed for the OAuth endpoints.
@@ -6858,23 +6804,6 @@ export function toolAccessService(
),
);
}
if (response.status === 401 && composioChild) {
const refreshed = await composioSessions.ensureSession(connection.id, {
force: true,
});
listRequestId = "paperclip-catalog-refresh-retry";
sessionHeaders = refreshed.headers;
response = await requestRemoteHttpEndpoint(new URL(refreshed.url), {
method: "POST",
headers: mcpHttpRequestHeaders(refreshed.headers),
body: JSON.stringify({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh-retry",
method: "tools/list",
params: {},
}),
});
}
if (
response.status === 401 &&
connection.credentialSource === "vercel_connect"
@@ -7045,432 +6974,6 @@ export function toolAccessService(
}));
}
function isComposioConnection(
connection: typeof toolConnections.$inferSelect,
): boolean {
return (
asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY && connection.transport === "rest_api"
);
}
async function composioClientForParent(
parent: typeof toolConnections.$inferSelect,
) {
if (!isComposioConnection(parent) || parent.transport !== "rest_api") {
throw unprocessable(
"This connection is not a parent Composio connection.",
{ code: "not_composio_parent" },
);
}
const headers = await resolveCredentialHeaders(parent);
const apiKey = Object.entries(headers).find(
([name]) => name.toLowerCase() === "x-api-key",
)?.[1];
if (!apiKey)
throw unprocessable("The Composio API key secret is missing.", {
code: "secret_missing",
});
return (
options.composioClientFactory?.(apiKey) ??
createComposioClient({ apiKey })
);
}
async function existingComposioChildren(
parent: typeof toolConnections.$inferSelect,
) {
const rows = await db
.select()
.from(toolConnections)
.where(
and(
eq(toolConnections.companyId, parent.companyId),
eq(toolConnections.applicationId, parent.applicationId),
),
);
return rows.filter(
(row) => composioChildConfig(row)?.parentConnectionId === parent.id,
);
}
async function assertComposioConnectedAccountActive(
child: typeof toolConnections.$inferSelect,
) {
const childConfig = composioChildConfig(child);
if (!childConfig) return;
const parent = await getConnectionRow(
childConfig.parentConnectionId,
child.companyId,
);
const client = await composioClientForParent(parent);
const accounts = await client.listConnectedAccounts({
toolkitSlugs: [childConfig.toolkitSlug],
userIds: [`paperclip:${child.companyId}`],
limit: 100,
});
const account = childConfig.connectedAccountId
? accounts.items.find(
(candidate) => candidate.id === childConfig.connectedAccountId,
)
: accounts.items.find(
(candidate) => candidate.toolkit.slug === childConfig.toolkitSlug,
);
if (account?.status.toUpperCase() === "ACTIVE") return;
const status = account?.status.trim().toUpperCase() || "MISSING";
throw unprocessable(
`Composio reports the ${childConfig.toolkitSlug} connected account as ${status}. Reconnect it in Composio.`,
{
code: "composio_connected_account_inactive",
connectedAccountStatus: status,
},
);
}
async function disableComposioChildren(
parent: typeof toolConnections.$inferSelect,
) {
const children = await existingComposioChildren(parent);
for (const child of children) {
if (child.status === "archived") continue;
const config = asRecord(child.config);
await db
.update(toolConnections)
.set({
enabled: false,
config: child.enabled
? { ...config, disabledByComposioParent: true }
: config,
updatedAt: now(),
})
.where(eq(toolConnections.id, child.id));
}
}
async function restoreComposioChildren(
parent: typeof toolConnections.$inferSelect,
) {
const children = await existingComposioChildren(parent);
const restorable = children.filter(
(child) =>
child.status !== "archived" &&
asRecord(child.config).disabledByComposioParent === true,
);
if (restorable.length === 0) return;
let accounts: Awaited<
ReturnType<ComposioClient["listConnectedAccounts"]>
>["items"] = [];
try {
const client = await composioClientForParent(parent);
accounts = (
await client.listConnectedAccounts({
userIds: [`paperclip:${parent.companyId}`],
limit: 1000,
})
).items;
} catch {
// Fail closed while Composio is unavailable. A later resume or reconnect
// can retry without exposing a child whose account state is unknown.
return;
}
for (const child of restorable) {
const childConfig = composioChildConfig(child)!;
const account = childConfig.connectedAccountId
? accounts.find(
(candidate) => candidate.id === childConfig.connectedAccountId,
)
: accounts.find(
(candidate) => candidate.toolkit.slug === childConfig.toolkitSlug,
);
const config = { ...asRecord(child.config) };
delete config.disabledByComposioParent;
const active = account?.status.toUpperCase() === "ACTIVE";
await db
.update(toolConnections)
.set({
enabled: active,
config: active
? config
: { ...config, disabledByComposioParent: true },
healthStatus: active ? "unchecked" : "degraded",
healthMessage: active
? null
: `Composio reports the ${childConfig.toolkitSlug} connected account as ${account?.status.toUpperCase() ?? "MISSING"}. Reconnect it in Composio.`,
updatedAt: now(),
})
.where(eq(toolConnections.id, child.id));
}
}
async function syncComposioChild(
parent: typeof toolConnections.$inferSelect,
account: { id: string; status: string; toolkit: { slug: string } },
toolkitName: string,
actor?: ActorInfo,
) {
if (account.status.toUpperCase() !== "ACTIVE") return null;
const children = await existingComposioChildren(parent);
const existing = children.find((candidate) => {
const config = composioChildConfig(candidate);
return (
config?.toolkitSlug === account.toolkit.slug &&
candidate.status !== "archived"
);
});
if (existing) {
const config = composioChildConfig(existing)!;
if (config.connectedAccountId !== account.id) {
const nextConfig = {
...existing.config,
connectedAccountId: account.id,
};
const [updated] = await db
.update(toolConnections)
.set({
config: nextConfig,
transportConfig: {
...existing.transportConfig,
connectedAccountId: account.id,
composioSessions: {},
},
updatedAt: now(),
})
.where(eq(toolConnections.id, existing.id))
.returning();
return updated;
}
return existing;
}
const connectionId = randomUUID();
const binding = actorBinding(actor);
const config = {
provider: "composio",
parentConnectionId: parent.id,
toolkitSlug: account.toolkit.slug,
connectedAccountId: account.id,
};
const [created] = await db
.insert(toolConnections)
.values({
id: connectionId,
companyId: parent.companyId,
applicationId: parent.applicationId,
name: `${toolkitName} (via Composio)`,
uid: connectionUid(
`composio:${parent.id}`,
account.toolkit.slug,
connectionId,
),
connectionKind: "managed",
ownership: parent.ownership,
transport: "mcp_remote",
authKind: "none",
credentialPolicy: "shared",
status: "active",
enabled: true,
config,
transportConfig: { ...config, composioSessions: {} },
credentialRefs: [],
credentialSecretRefs: [],
createdByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
})
.returning();
if (!created)
throw new Error("Failed to create Composio toolkit connection");
await ensureDefaultOrganizationGrant(created);
await syncCredentialBindings(created);
await ensureRuntimeSlot(created);
await audit({
companyId: created.companyId,
connectionId: created.id,
action: "composio.child_created",
outcome: "success",
actor,
details: {
parentConnectionId: parent.id,
toolkitSlug: account.toolkit.slug,
},
});
return created;
}
async function syncComposioToolkit(
parent: typeof toolConnections.$inferSelect,
toolkitSlug: string,
actor?: ActorInfo,
) {
const client = await composioClientForParent(parent);
const userId = `paperclip:${parent.companyId}`;
const [toolkits, accounts] = await Promise.all([
client.listToolkits({ limit: 1000 }),
client.listConnectedAccounts({
toolkitSlugs: [toolkitSlug],
userIds: [userId],
limit: 100,
}),
]);
const toolkit = toolkits.items.find((item) => item.slug === toolkitSlug);
if (!toolkit) throw notFound("Composio toolkit not found");
const account =
accounts.items.find(
(item) =>
item.toolkit.slug === toolkitSlug &&
item.status.toUpperCase() === "ACTIVE",
) ??
accounts.items.find((item) => item.toolkit.slug === toolkitSlug) ??
null;
const child = account
? await syncComposioChild(parent, account, toolkit.name, actor)
: null;
if (child)
await refreshCatalog(child.id, actor, { enableAllByDefault: true });
return {
toolkit,
account,
child: child ? toConnection(await getConnectionRow(child.id)) : null,
};
}
async function validateComposioConnection(
connection: typeof toolConnections.$inferSelect,
) {
const client = await composioClientForParent(connection);
await client.validateApiKey();
}
async function listComposioServices(
parentConnectionId: string,
actor?: ActorInfo,
) {
const parent = await getConnectionRow(parentConnectionId);
const client = await composioClientForParent(parent);
const userId = `paperclip:${parent.companyId}`;
const [toolkits, accounts] = await Promise.all([
client.listToolkits({ limit: 1000 }),
client.listConnectedAccounts({ userIds: [userId], limit: 1000 }),
]);
const children = await existingComposioChildren(parent);
const childByToolkit = new Map(
children
.filter((child) => child.status !== "archived")
.map((child) => [composioChildConfig(child)?.toolkitSlug, child]),
);
const services = [];
for (const toolkit of toolkits.items) {
const toolkitAccounts = accounts.items.filter(
(account) => account.toolkit.slug === toolkit.slug,
);
const account =
toolkitAccounts.find(
(candidate) => candidate.status.toUpperCase() === "ACTIVE",
) ??
toolkitAccounts[0] ??
null;
let child = childByToolkit.get(toolkit.slug) ?? null;
if (account?.status.toUpperCase() === "ACTIVE" && !child) {
child = await syncComposioChild(parent, account, toolkit.name, actor);
if (child)
await refreshCatalog(child.id, actor, { enableAllByDefault: true });
}
services.push({
toolkit,
status:
account?.status.toUpperCase() === "ACTIVE"
? "connected"
: account
? "pending"
: "not_connected",
connectedAccountId: account?.id ?? null,
connectedAccountStatus: account?.status ?? null,
childConnectionId: child?.id ?? null,
});
}
return { parentConnectionId: parent.id, userId, services };
}
async function startComposioServiceConnect(
parentConnectionId: string,
toolkitSlug: string,
input: { authConfigId?: string; callbackUrl?: string },
) {
const parent = await getConnectionRow(parentConnectionId);
const client = await composioClientForParent(parent);
let authConfigId = input.authConfigId?.trim();
if (!authConfigId) {
const configs = await client.listAuthConfigs({
toolkitSlugs: [toolkitSlug],
showDisabled: false,
limit: 100,
});
authConfigId = configs.items.find(
(config) =>
config.toolkit.slug === toolkitSlug && config.status !== "DISABLED",
)?.id;
}
if (!authConfigId)
throw unprocessable(
"This Composio toolkit has no enabled auth configuration.",
{ code: "composio_auth_config_missing" },
);
const link = await client.createConnectLink({
authConfigId,
userId: `paperclip:${parent.companyId}`,
alias: `paperclip-${parent.companyId}-${toolkitSlug}`,
...(input.callbackUrl ? { callbackUrl: input.callbackUrl } : {}),
});
return { toolkitSlug, authConfigId, ...link };
}
async function disconnectComposioService(
parentConnectionId: string,
toolkitSlug: string,
actor?: ActorInfo,
) {
const parent = await getConnectionRow(parentConnectionId);
const client = await composioClientForParent(parent);
const accounts = await client.listConnectedAccounts({
toolkitSlugs: [toolkitSlug],
userIds: [`paperclip:${parent.companyId}`],
limit: 100,
});
for (const account of accounts.items.filter(
(candidate) => candidate.toolkit.slug === toolkitSlug,
)) {
await client.deleteConnectedAccount(account.id);
}
const children = await existingComposioChildren(parent);
const removedChildIds: string[] = [];
for (const child of children) {
if (
composioChildConfig(child)?.toolkitSlug !== toolkitSlug ||
child.status === "archived"
)
continue;
await removeConnection(child.id, child.companyId, actor);
removedChildIds.push(child.id);
}
await audit({
companyId: parent.companyId,
connectionId: parent.id,
action: "composio.service_disconnected",
outcome: "success",
actor,
details: {
toolkitSlug,
connectedAccountCount: accounts.items.length,
removedChildCount: removedChildIds.length,
},
});
return {
toolkitSlug,
disconnectedAccountIds: accounts.items.map((account) => account.id),
removedChildIds,
};
}
function isAgentMailConnection(connection: typeof toolConnections.$inferSelect) {
return connection.transport === "rest_api" && connection.config.provider === "agentmail";
}
@@ -7504,11 +7007,18 @@ export function toolAccessService(
await agentmailApi(key).whoami();
}
function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) {
if (isRetiredComposioConnection(connection)) {
throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" });
}
}
async function discoverTools(
connection: typeof toolConnections.$inferSelect,
credentialHeaders?: Record<string, string>,
actor?: ActorInfo,
): Promise<McpToolDescriptor[]> {
assertSupportedConnection(connection);
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
if (isAgentMailConnection(connection)) {
await validateAgentMailConnection(connection);
@@ -7516,10 +7026,6 @@ export function toolAccessService(
}
if (connection.transport === "mcp_remote")
return remoteTools(connection, credentialHeaders, actor);
if (isComposioConnection(connection)) {
await validateComposioConnection(connection);
return [];
}
if (connection.transport !== "local_stdio") {
throw unsupportedToolConnectionTransport();
}
@@ -7623,6 +7129,7 @@ export function toolAccessService(
const connection = await getConnectionRow(connectionId);
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
try {
assertSupportedConnection(connection);
const config = asRecord(connection.config);
const oauth = asRecord(config.oauth);
if (
@@ -7665,7 +7172,6 @@ export function toolAccessService(
} else if (isAgentMailConnection(connection)) {
await validateAgentMailConnection(connection);
} else if (connection.transport === "mcp_remote") {
await assertComposioConnectedAccountActive(connection);
const canProbeWithoutAuthorization =
options.allowUnauthenticatedProbe === true &&
connection.status === "draft" &&
@@ -7682,8 +7188,6 @@ export function toolAccessService(
? {}
: undefined;
await remoteTools(connection, credentialHeaders, actor);
} else if (isComposioConnection(connection)) {
await validateComposioConnection(connection);
} else if (connection.transport === "local_stdio") {
await resolveCredentialHeaders(connection);
await stdioTemplateId(connection.companyId, connection.config);
@@ -7698,11 +7202,9 @@ export function toolAccessService(
? "GitHub account, installation, and repository access are available."
: isAgentMailConnection(connection)
? "AgentMail API key is connected."
: isComposioConnection(connection)
? "Composio accepted the API key and returned its toolkits."
: connection.transport === "local_stdio"
? "Approved stdio template is ready."
: "Remote MCP server responded to tools/list.",
: connection.transport === "local_stdio"
? "Approved stdio template is ready."
: "Remote MCP server responded to tools/list.",
);
const runtimeSlot = await ensureRuntimeSlot(updated);
await audit({
@@ -12312,6 +11814,7 @@ export function toolAccessService(
),
);
if (!connection) throw notFound("Incomplete app connection not found");
assertSupportedConnection(connection);
if (input.resumeConnectionId && connection.status !== "draft") {
throw conflict("Only an incomplete app connection can resume setup", {
code: "connection_setup_not_incomplete",
@@ -13497,23 +13000,6 @@ export function toolAccessService(
// later fails: another retry may already be using the committed grant.
personalPublicSetupEstablished = true;
}
if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY && transport === "rest_api") {
const [application] = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, applicationRow.id));
return {
connectionId: health.connection.id,
application: toApplication(application),
connection: health.connection,
catalog: [],
actions: { readOnly: [], canMakeChanges: [] },
suggestedDefaults: recommendedDefaultsForApp(
galleryEntry,
method?.key,
),
};
}
const restoreDraftDefaults = Boolean(revivedConnectionPrevious);
const refreshOptions = {
enableAllByDefault: restoreDraftDefaults && !remoteMcpConnector,
@@ -14344,6 +13830,7 @@ export function toolAccessService(
actor?: ActorInfo,
): Promise<ToolConnectionHealthCheckResult> {
const connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey);
if (connection.status === "archived")
throw conflict("Archived app connections cannot be reconnected");
@@ -14498,13 +13985,6 @@ export function toolAccessService(
personalIdentity ? credentialSecretRefs : [],
);
const health = await checkConnectionHealth(updated.id, actor);
if (
isComposioConnection(updated) &&
updated.enabled &&
updated.status === "active"
) {
await restoreComposioChildren(updated);
}
const refresh = await refreshCatalog(updated.id, actor, {
enableAllByDefault: true,
});
@@ -14526,6 +14006,7 @@ export function toolAccessService(
},
): Promise<ToolOAuthStartResult> {
let connection = await getConnectionRow(connectionId, companyId);
assertSupportedConnection(connection);
await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey);
if (connection.status === "archived")
throw conflict("Archived app connections cannot start sign in");
@@ -17732,21 +17213,6 @@ export function toolAccessService(
return connections;
},
listComposioServices,
startComposioServiceConnect,
pollComposioService: async (
parentConnectionId: string,
toolkitSlug: string,
actor?: ActorInfo,
) => {
const parent = await getConnectionRow(parentConnectionId);
return syncComposioToolkit(parent, toolkitSlug, actor);
},
disconnectComposioService,
createConnection: async (
companyId: string,
input: CreateToolConnection,
@@ -17830,14 +17296,6 @@ export function toolAccessService(
await ensureDefaultOrganizationGrant(row);
await syncCredentialBindings(row);
await ensureRuntimeSlot(row);
if (
isComposioConnection(row) &&
(input.enabled !== undefined || input.status !== undefined)
) {
if (!row.enabled || row.status !== "active")
await disableComposioChildren(row);
else await restoreComposioChildren(row);
}
return toConnection(row);
},
@@ -18728,10 +18186,6 @@ export function toolAccessService(
.returning();
await syncCredentialBindings(row);
await ensureRuntimeSlot(row);
if (isComposioConnection(row)) {
if (row.enabled) await restoreComposioChildren(row);
else await disableComposioChildren(row);
}
return toConnection(row);
},
+11 -48
View File
@@ -122,11 +122,7 @@ import {
type ToolRuntimeSlotView,
} from "./tool-runtime-supervisor.js";
import { recordToolRuntimeAuditWriteFailure } from "./tool-runtime-metrics.js";
import {
composioChildConfig,
createComposioSessionManager,
} from "./composio-session-manager.js";
import type { ComposioClient } from "./composio.js";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
import {
createPaperclipCloudConnector,
isPaperclipCloudConnectorStrategy,
@@ -1021,8 +1017,6 @@ export function createToolGatewayService(
onToolActionSettled?: (actionRequestId: string) => Promise<unknown>;
/** Test seam for deterministic remote MCP protocol fixtures. */
remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response>;
/** Test seam for Composio session creation without vendor traffic. */
composioClientFactory?: (apiKey: string) => ComposioClient;
/** Test seam for refreshing personal Gmail grants. */
paperclipCloudConnector?: PaperclipCloudConnector | null;
/** @deprecated Use paperclipCloudConnector. */
@@ -1119,10 +1113,6 @@ export function createToolGatewayService(
options.vercelConnectClient === undefined
? createVercelConnectClient()
: options.vercelConnectClient;
const composioSessions = createComposioSessionManager(db, {
composioClientFactory: options.composioClientFactory,
now: options.now ? () => new Date(options.now!()) : undefined,
});
const protocolLimits = mcpGatewayProtocolLimits(
options.mcpGatewayProtocolLimits,
);
@@ -1249,6 +1239,7 @@ export function createToolGatewayService(
const eligibleRows = rows.filter(
({ catalogEntry, connection, application }) =>
!isRetiredComposioConnection(connection) &&
!(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) &&
((connection.transport === "mcp_remote" &&
application.type === "mcp_http") ||
@@ -4693,6 +4684,9 @@ export function createToolGatewayService(
"tool_not_found",
);
}
if (isRetiredComposioConnection(connection)) {
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
}
if (!connection.enabled || connection.status !== "active") {
throw new ToolGatewayHttpError(
403,
@@ -4783,6 +4777,9 @@ export function createToolGatewayService(
"tool_not_found",
);
}
if (isRetiredComposioConnection(connection)) {
throw new ToolGatewayHttpError(422, RETIRED_COMPOSIO_MESSAGE, "composio_broker_retired");
}
if (!connection.enabled || connection.status !== "active") {
throw new ToolGatewayHttpError(
403,
@@ -5785,21 +5782,11 @@ export function createToolGatewayService(
ms = railwayCommandBudgetMs(parameters);
}
const grant = await resolveConnectionGrant(session, connection);
const composioScopeRevision = `${grant.id}:${grant.status}:${grant.updatedAt.toISOString()}`;
const composioChild = composioChildConfig(connection);
let composioSession = composioChild
? await composioSessions.ensureSession(connection.id, {
tools: [entry.toolName],
scopeRevision: composioScopeRevision,
})
: null;
let endpoint =
composioSession?.url ??
(await resolvedRemoteEndpoint(session, connection, grant));
const endpoint = await resolvedRemoteEndpoint(session, connection, grant);
// Method-defined headers are trusted catalog configuration. Treat them as
// managed headers so callers cannot override the scope that was reviewed
// during tools/list. Credentials remain authoritative on collisions.
let credentialHeaders = composioSession?.headers ?? {
let credentialHeaders = {
...projectedConnectionHeaders(connection),
...(await resolveCredentialHeaders(session, connection, grant)),
};
@@ -5817,9 +5804,7 @@ export function createToolGatewayService(
request: {
protocol: "MCP JSON-RPC 2.0",
httpMethod: "POST",
endpoint: composioChild
? `${new URL(endpoint).origin}/[composio-session]`
: auditSafeEndpoint(endpoint),
endpoint: auditSafeEndpoint(endpoint),
mcpMethod: "tools/call",
requestId,
upstreamToolName: entry.toolName,
@@ -5905,28 +5890,6 @@ export function createToolGatewayService(
}),
};
let response = await dispatchRemote(endpoint, requestInit);
if (response.status === 401 && composioChild) {
composioSession = await composioSessions.ensureSession(connection.id, {
tools: [entry.toolName],
scopeRevision: composioScopeRevision,
force: true,
});
endpoint = composioSession.url;
credentialHeaders = composioSession.headers;
builtHeaders = buildRemoteHeaders({
session,
connection,
credentialHeaders,
callerHeaders,
});
headers = builtHeaders.headers;
headerSummary = builtHeaders.summary;
const retryInit = {
...requestInit,
headers: mcpHttpRequestHeaders(headers),
};
response = await dispatchRemote(endpoint, retryInit);
}
const oauth = asRecord(asRecord(connection.config)?.oauth);
if (
response.status === 401 &&
+2 -26
View File
@@ -1,9 +1,3 @@
import type {
ComposioConnectLinkResponse,
ComposioDisconnectResponse,
ComposioServiceStatusResponse,
ComposioServicesResponse,
} from "@/pages/apps/composio-services";
import type {
ToolApplication,
ConfigureRailwaySsh,
@@ -411,9 +405,9 @@ export const toolsApi = {
api.post<RailwaySshSetup | null>(`/tool-connections/${connectionId}/railway/ssh`, input),
// Removal is a credential-revoking teardown (PAP-17119), so the response
// carries the cleanup receipt alongside the archived connection.
archiveConnection: (connectionId: string, options: { confirmComposioChildren?: boolean } = {}) =>
archiveConnection: (connectionId: string) =>
api.delete<ToolConnection & { removal: ToolConnectionRemovalSummary }>(
`/tool-connections/${connectionId}${options.confirmComposioChildren ? "?confirmComposioChildren=true" : ""}`,
`/tool-connections/${connectionId}`,
),
checkConnectionHealth: (connectionId: string) =>
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/health-check`, {}),
@@ -449,24 +443,6 @@ export const toolsApi = {
api.get<ToolConnectionTestCallStatus>(
`/tool-connections/${connectionId}/test-calls/${actionRequestId}`,
),
// --- Composio services (PAP-17865) ---
// A Composio connection brokers many toolkits; these four read and change the
// per-toolkit state the Services tab renders.
listComposioServices: (connectionId: string) =>
api.get<ComposioServicesResponse>(`/tool-connections/${connectionId}/services`),
startComposioServiceConnect: (connectionId: string, toolkitSlug: string) =>
api.post<ComposioConnectLinkResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/connect`,
{},
),
getComposioServiceStatus: (connectionId: string, toolkitSlug: string) =>
api.get<ComposioServiceStatusResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}/status`,
),
disconnectComposioService: (connectionId: string, toolkitSlug: string) =>
api.delete<ComposioDisconnectResponse>(
`/tool-connections/${connectionId}/services/${encodeURIComponent(toolkitSlug)}`,
),
importMcpJson: (companyId: string, body: { mcpJson: unknown }) =>
api.post<McpJsonImportPreview>(`/companies/${companyId}/tools/mcp/import-json`, body),
listStdioTemplates: (companyId: string) =>
@@ -9,7 +9,6 @@ import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import {
APP_TABS,
CONNECTED_ONLY_APP_TABS,
appApplicationTabHref,
appTabHref,
type AppTabKey,
@@ -101,9 +100,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter(
(tab) => props.kind === "connection" || !CONNECTED_ONLY_APP_TABS.has(tab.key),
).map((tab) => (
{APP_TABS.map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
+1 -14
View File
@@ -9,13 +9,10 @@ import { useSidebar } from "@/context/SidebarContext";
import { queryKeys } from "@/lib/queryKeys";
import {
APP_TABS,
BROKER_ONLY_APP_TABS,
CONNECTED_ONLY_APP_TABS,
appApplicationTabHref,
appTabHref,
type AppTabKey,
} from "@/pages/apps/app-tabs";
import { isComposioBrokerConnection } from "@/pages/apps/composio-services";
import { AppLogo } from "@/pages/apps/AppLogo";
import {
appApplicationSourceSlug,
@@ -64,7 +61,6 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
});
const connection = connectionQuery.data;
const isBroker = isComposioBrokerConnection(connection);
const applicationId = props.kind === "application" ? props.applicationId : connection?.applicationId;
const application = (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId) ?? null;
const appConnections = props.kind === "application"
@@ -116,7 +112,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<nav className="scrollbar-auto-hide min-h-0 flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-0.5">
{APP_TABS.filter((tab) => visibleTab(tab.key, props.kind, isBroker)).map((tab) => (
{APP_TABS.map((tab) => (
<SidebarNavItem
key={tab.key}
to={tabHref(props, tab.key)}
@@ -134,15 +130,6 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
);
}
function visibleTab(
tab: AppTabKey,
kind: AppDetailSidebarProps["kind"],
isBroker: boolean,
): boolean {
if (kind !== "connection" && CONNECTED_ONLY_APP_TABS.has(tab)) return false;
return !BROKER_ONLY_APP_TABS.has(tab) || isBroker;
}
function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
return props.kind === "connection"
? appTabHref(props.connectionId, tab)
@@ -44,7 +44,7 @@ export const remoteMcpProviders: Record<RemoteMcpProviderId, RemoteMcpProvider>
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.",
authHelp: "Session URLs and headers come from your external Composio setup. Direct-tools sessions expose individual actions. Existing project API-key connections are managed separately.",
authHelp: "Session URLs and headers come from your external Composio setup. Direct-tools sessions expose individual actions.",
},
executor: {
id: "executor", name: "Executor", supportsBrowserAuth: true,
-2
View File
@@ -50,8 +50,6 @@ export const queryKeys = {
["tools", "connection", connectionId, "installs"] as const,
connectionGrants: (connectionId: string) =>
["tools", "connection", connectionId, "grants"] as const,
composioServices: (connectionId: string) =>
["tools", "connection", connectionId, "composio-services"] as const,
catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const,
connectionActivity: (connectionId: string) =>
["tools", "connection", connectionId, "activity"] as const,
-98
View File
@@ -17,9 +17,6 @@ import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls";
import { useState } from "react";
import { ServicesList } from "./apps/app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
import type { ComposioServiceRow } from "./apps/composio-services";
import {
BookOpen,
Bot,
@@ -274,58 +271,6 @@ const DESIGN_GUIDE_TASK = {
/* Section wrapper */
/* ------------------------------------------------------------------ */
/**
* Composio service rows for the design guide (PAP-17865). One row per state, so
* a reader can compare all four side by side rather than connecting a real
* Composio project to see them.
*/
const DESIGN_GUIDE_COMPOSIO_ROWS: ComposioServiceRow[] = [
{
toolkitSlug: "github",
name: "GitHub",
description: "Issues, pull requests, and repository actions",
logoUrl: null,
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "design-guide-child",
toolCount: 42,
noAuth: false,
},
{
toolkitSlug: "hubspot",
name: "HubSpot",
description: "CRM contacts and deals",
logoUrl: null,
state: "attention",
connectedAccountStatus: "EXPIRED",
childConnectionId: "design-guide-child-2",
toolCount: 18,
noAuth: false,
},
{
toolkitSlug: "slack",
name: "Slack",
description: "Channels and messages",
logoUrl: null,
state: "pending",
connectedAccountStatus: "INITIALIZING",
childConnectionId: null,
toolCount: 12,
noAuth: false,
},
{
toolkitSlug: "gmail",
name: "Gmail",
description: "Read and send mail",
logoUrl: null,
state: "not_connected",
connectedAccountStatus: null,
childConnectionId: null,
toolCount: 9,
noAuth: false,
},
];
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-4">
@@ -2195,49 +2140,6 @@ export function DesignGuide() {
</SubSection>
</Section>
<Section title="Composio Services">
<p className="text-sm text-muted-foreground">
A broker connection (Composio) fronts many services, so its detail page lists toolkits
with per-service state instead of one credential. Row state comes from Composio's own
account status, which is why there is a fourth <code>attention</code> state alongside the
three the design asks for: an expired credential is neither connected nor still settling.
</p>
<SubSection title="Row states">
<ServicesList
rows={DESIGN_GUIDE_COMPOSIO_ROWS}
busySlug={null}
onConnect={() => {}}
onRecheck={() => {}}
onDisconnect={() => {}}
/>
</SubSection>
<SubSection title="Busy row">
<ServicesList
rows={[DESIGN_GUIDE_COMPOSIO_ROWS[2]!]}
busySlug={DESIGN_GUIDE_COMPOSIO_ROWS[2]!.toolkitSlug}
onConnect={() => {}}
onRecheck={() => {}}
onDisconnect={() => {}}
/>
</SubSection>
<SubSection title="Provenance chip">
<p className="mb-2 text-xs text-muted-foreground">
Shown wherever a brokered child connection appears, so the parent/child coupling is
legible. Links to the broker's Services tab when the parent is known.
</p>
<div className="flex items-center gap-3">
<ComposioProvenanceChip
connection={{
config: { provider: "composio", parentConnectionId: "parent-1", toolkitSlug: "github" },
}}
/>
<ComposioProvenanceChip
connection={{ config: { provider: "composio", toolkitSlug: "gmail" } }}
/>
</div>
</SubSection>
</Section>
<Section title="Source Repositories">
<SubSection title="Empty and disconnected">
<RepositoryEditor selected={[]} onChange={() => {}} state="disconnected" onConnect={() => {}} onRetry={() => {}} />
@@ -214,7 +214,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const selector = 'button[aria-label="Toggle MCP aggregators experimental setting"]';
expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false");
expect(container.textContent).toContain("legacy Composio connections are unchanged");
expect(container.textContent).toContain("Existing MCP connections keep running.");
for (const enabled of [true, false]) {
await act(() => container.querySelector<HTMLButtonElement>(selector)!.click());
await flushReact();
@@ -341,7 +341,7 @@ export function InstanceExperimentalSettings() {
<ExperimentalToggleCard
title="MCP aggregators"
description="Connect Zapier, Arcade, Composio Connect, and Executor through their MCP servers."
footnote="Turning this off hides setup for these connectors. Existing connections keep running, and legacy Composio connections are unchanged."
footnote="Turning this off hides setup for these connectors. Existing MCP connections keep running."
checked={experimentalQuery.data?.enableMcpAggregators === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableMcpAggregators: checked })}
disabled={toggleMutation.isPending}
+19 -1
View File
@@ -454,10 +454,28 @@ describe("AppDetail", () => {
await flushReact();
}
it.each([
{ transport: "rest_api", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" } },
{ transport: "mcp_remote", config: { provider: "composio", parentConnectionId: "old-parent", toolkitSlug: "github" } },
])("shows replacement and explicit removal for a retired Composio record", async (legacy) => {
getConnectionMock.mockResolvedValue(connection({ ...legacy, enabled: false, healthStatus: "error" }));
await renderAppDetail();
expect(container.textContent).toContain("Connection retired");
expect(container.textContent).toContain("Remove each obsolete connection separately");
expect(container.textContent).not.toContain("Paused");
expect(container.textContent).not.toContain("Refresh actions");
const replacement = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Add Composio MCP connection")!;
await act(async () => replacement.click());
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=composio");
const danger = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes("Danger zone"))!;
await act(async () => danger.click());
expect(container.textContent).toContain("Remove app");
expect(Array.from(container.querySelectorAll("button")).some((b) => b.textContent === "Reconnect")).toBe(false);
});
it("uses Permissions as the primary connection page and has no Setup tab", () => {
expect(APP_TABS.map((tab) => tab.key)).toEqual([
"permissions",
"services",
"review",
]);
});
+20 -6
View File
@@ -1,4 +1,4 @@
import { isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE, isRemoteMcpConnectorId, isRemoteMcpConnectorMethod } from "@paperclipai/shared";
import { RemoteMcpManagement } from "@/features/connections/remote-mcp/RemoteMcpManagement";
import { remoteMcpProviders } from "@/features/connections/remote-mcp/providers";
import { ManagedAiConnectionDetails } from "@/components/ai-connections/ManagedAiConnectionDetails";
@@ -47,14 +47,14 @@ import {
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
import { ServicesPanel } from "./app-detail/ServicesPanel";
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip";
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
import {
ReconnectCard,
DangerZone,
connectionAddress,
connectionTransportLabel,
} from "./app-detail/AdvancedPanel";
@@ -505,6 +505,23 @@ export function AppDetail({ renderActions, onReconnect }: {
);
}
if (isRetiredComposioConnection(connection)) {
return <div className="max-w-4xl space-y-6 pb-12">
<h1 className="text-xl font-semibold">{appName}</h1>
<section role="status" className="space-y-3 rounded-lg border border-border bg-muted p-4">
<h2 className="text-sm font-semibold">Connection retired</h2>
<p className="text-sm text-muted-foreground">{RETIRED_COMPOSIO_MESSAGE}</p>
<p className="text-sm text-muted-foreground">Remove each obsolete connection separately. Removing this one does not remove other connections.</p>
<Button variant="outline" onClick={() => navigate("/apps/connect?source=composio")}>Add Composio MCP connection</Button>
</section>
{grantsQuery.data?.capabilities.canConfigure === true && <DangerZone
appName={appName}
removing={disconnectRemote.isPending}
onRemove={() => disconnectRemote.mutate()}
/>}
</div>;
}
const aiGrantRevoked = connection.connectionPurpose === "ai"
&& grantRows.length > 0 && grantRows.every((grant) => grant.status === "revoked");
const status: StatusInfo = aiGrantRevoked ? { label: "Revoked", tone: "attention" } : statusFor(connection);
@@ -569,9 +586,6 @@ export function AppDetail({ renderActions, onReconnect }: {
/>
)}
{activeTab === "services" && (
<ServicesPanel connectionId={connectionId} appName={appName} />
)}
{activeTab === "review" && (
reviewFailed
? <ToolsLoadError onRetry={() => {
-3
View File
@@ -117,9 +117,6 @@ export function AppNotConnected() {
if (activeConnection) {
return <Navigate to={appTabHref(activeConnection.id, activeTab)} replace />;
}
if (activeTab === "services") {
return <Navigate to={appApplicationTabHref(applicationId, "permissions")} replace />;
}
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const logoEntry = (appSourceSlug
+5 -6
View File
@@ -59,7 +59,6 @@ const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!;
const COMPOSIO = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
@@ -636,8 +635,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
* methods must still let the operator deliberately choose a personal identity.
*/
it("defaults flexible methods to company identity and keeps personal credentials submittable", async () => {
mockSearch.value = "source=composio&method=api-key";
listGalleryMock.mockResolvedValue({ apps: [{ ...COMPOSIO, methods: COMPOSIO.methods.filter((method) => method.key === "api-key") }, POSTHOG] });
mockSearch.value = "source=posthog&method=mcp-api-key";
listGalleryMock.mockResolvedValue({ apps: [{ ...POSTHOG, methods: POSTHOG.methods.filter((method) => method.key === "mcp-api-key") }] });
const identityChoices = () => {
const radios = Array.from(
@@ -652,7 +651,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// --- API-key-only method: shared by default, personal still offered ------
let root = await render();
await act(async () => {
buttonContaining("Composio")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonContaining("PostHog")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@@ -682,7 +681,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await flushReact();
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
await act(async () => setInputValue(keyField!, "composio-personal-token"));
await act(async () => setInputValue(keyField!, "posthog-personal-token"));
await flushReact();
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -693,7 +692,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// a personal grant. A disabled "Just me" would make this unreachable.
expect(connectAppMock).toHaveBeenCalledTimes(1);
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
galleryKey: "composio",
galleryKey: "posthog",
grantKind: "user",
});
+14 -8
View File
@@ -28,8 +28,7 @@ vi.mock("@/api/tools", () => ({
listConnections: (companyId: string) => listConnectionsMock(companyId),
archiveConnection: (
connectionId: string,
options?: { confirmComposioChildren?: boolean },
) => archiveConnectionMock(connectionId, options),
) => archiveConnectionMock(connectionId),
},
}));
@@ -190,7 +189,16 @@ describe("Connectors landing page", () => {
return client;
}
it("hides cached MCP aggregators until enabled and preserves existing legacy connections", async () => {
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" } })] });
await renderBrowse();
expect(container.textContent).toContain("Retired");
expect(container.textContent).toContain("Add a new Composio MCP connection");
expect(container.textContent).not.toContain("Paused");
});
it("hides cached MCP aggregators until enabled and preserves saved MCP connections", async () => {
const providers = ["zapier", "arcade", "composio", "executor"];
listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) });
const client = await renderBrowse();
@@ -200,8 +208,8 @@ describe("Connectors landing page", () => {
await flushReact();
for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull();
await act(() => {
client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "legacy", applicationId: "legacy-app", config: { sourceTemplateKey: "composio", connectionMethodKey: "api-key" }, transport: "rest_api" })] });
client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "legacy-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "saved", applicationId: "saved-app", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" }, transport: "mcp_remote" })] });
client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "saved-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false });
});
await flushReact();
@@ -451,9 +459,7 @@ describe("Connectors landing page", () => {
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("conn-notion", {
confirmComposioChildren: false,
});
expect(archiveConnectionMock).toHaveBeenCalledWith("conn-notion");
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection removed",
+7 -16
View File
@@ -1,3 +1,4 @@
import { 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";
@@ -74,7 +75,6 @@ import {
appSourceResumeHref,
appSupportsToolCatalogSetup,
} from "./app-connect-policy";
import { composioChildParentConnectionId } from "./composio-services";
import {
ConnectionOwnerIdentity,
connectionDisplayNameForOwner,
@@ -107,7 +107,7 @@ type ConnectionRemovalTarget = {
accountName: string;
providerName: string;
remainingConnectionCount: number;
childConnectionCount: number;
};
function chatProviderForSlug(slug: string): ChatProvider | null {
@@ -163,6 +163,9 @@ function additionalConnectionHref(
}
function connectionState(connection: ToolConnection): ConnectionState {
if (isRetiredComposioConnection(connection)) {
return { kind: "attention", label: "Retired", message: RETIRED_COMPOSIO_MESSAGE };
}
if (connection.status === "draft") {
return {
kind: "draft",
@@ -318,9 +321,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
});
const removeConnection = useMutation({
mutationFn: (target: ConnectionRemovalTarget) =>
toolsApi.archiveConnection(target.id, {
confirmComposioChildren: target.childConnectionCount > 0,
}),
toolsApi.archiveConnection(target.id),
onSuccess: (_connection, target) => {
queryClient.invalidateQueries({
queryKey: queryKeys.tools.connections(selectedCompanyId!),
@@ -685,7 +686,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
renderAccountDetails={renderAccountDetails}
key={row.key}
row={row}
allConnections={connectionsQuery.data?.connections ?? []}
userProfileById={userProfileById}
onNavigate={navigate}
onRequestRemove={setConnectionToRemove}
@@ -711,9 +711,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
Remove {connectionToRemove?.accountName ?? "this"} connection?
</AlertDialogTitle>
<AlertDialogDescription>
{connectionToRemove && connectionToRemove.childConnectionCount > 0
? `This also removes ${connectionToRemove.childConnectionCount} connected ${connectionToRemove.childConnectionCount === 1 ? "service" : "services"} and takes agent access away immediately. The Composio key and child session credentials are deleted.`
: connectionToRemove &&
{connectionToRemove &&
connectionToRemove.remainingConnectionCount > 0
? `This connection's saved credentials are deleted and agents lose access through it immediately. They can still use ${connectionToRemove.providerName} through ${connectionToRemove.remainingConnectionCount} other active ${connectionToRemove.remainingConnectionCount === 1 ? "connection" : "connections"}.`
: "The saved credentials are deleted and agents lose access immediately. Connecting it again later requires a new sign-in or key."}
@@ -749,7 +747,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
export function ConnectorCard({
renderAccountDetails,
row,
allConnections,
userProfileById,
onNavigate,
onRequestRemove,
@@ -758,7 +755,6 @@ export function ConnectorCard({
}: {
renderAccountDetails?: (connection: ToolConnection) => ReactNode;
row: ConnectorRowModel;
allConnections: ToolConnection[];
userProfileById: ReadonlyMap<string, ConnectionOwnerProfile>;
onNavigate: (href: string) => void;
onRequestRemove: (target: ConnectionRemovalTarget) => void;
@@ -836,11 +832,6 @@ export function ConnectorCard({
candidate.status === "active" &&
candidate.enabled,
).length,
childConnectionCount: allConnections.filter(
(candidate) =>
composioChildParentConnectionId(candidate) ===
connection.id,
).length,
});
}}
/>
@@ -1,73 +0,0 @@
import { Blocks } from "lucide-react";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { appTabHref } from "./app-tabs";
import { composioChildParentConnectionId, composioChildToolkitSlug } from "./composio-services";
/**
* "via Composio" on a connection that Composio brokers (PAP-17865).
*
* A toolkit child looks like any other app in the connections list, which hides
* the coupling that matters most: its credentials live in Composio, and pausing
* or removing the Composio connection takes it down too. The chip states that
* relationship wherever the child appears, and links to the broker's Services tab
* so the parent is one click away.
*/
export function ConnectionProvenanceChip({
connection,
className,
}: {
connection: {
config?: Record<string, unknown> | null;
credentialSource?: string;
externalCredential?: { connectorUid?: string } | null;
} | null | undefined;
className?: string;
}) {
const chipClass = cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
className,
);
if (connection?.credentialSource === "vercel_connect") {
const connectorUid = connection.externalCredential?.connectorUid;
return (
<span
className={chipClass}
title={connectorUid ? `Credentials managed by Vercel Connect (${connectorUid})` : "Credentials managed by Vercel Connect"}
>
<Blocks className="h-3 w-3" />
via Vercel Connect
</span>
);
}
const toolkitSlug = composioChildToolkitSlug(connection);
if (!toolkitSlug) return null;
const parentConnectionId = composioChildParentConnectionId(connection);
const label = (
<>
<Blocks className="h-3 w-3" />
via Composio
</>
);
// Without a parent id there is nowhere to send the reader, so the chip stays a
// label rather than becoming a dead link.
if (!parentConnectionId) {
return <span className={chipClass} title={`Brokered by Composio (${toolkitSlug})`}>{label}</span>;
}
return (
<Link
to={appTabHref(parentConnectionId, "services")}
className={cn(chipClass, "transition-colors hover:bg-accent hover:text-accent-foreground")}
title={`Brokered by Composio (${toolkitSlug}) — open the Composio Services tab`}
onClick={(event) => event.stopPropagation()}
>
{label}
</Link>
);
}
/** Backward-compatible name for callers outside the Apps v2 surfaces. */
export const ComposioProvenanceChip = ConnectionProvenanceChip;
@@ -0,0 +1,33 @@
import { Blocks } from "lucide-react";
import { cn } from "@/lib/utils";
export function ConnectionProvenanceChip({
connection,
className,
}: {
connection: {
config?: Record<string, unknown> | null;
credentialSource?: string;
externalCredential?: { connectorUid?: string } | null;
} | null | undefined;
className?: string;
}) {
const chipClass = cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground",
className,
);
if (connection?.credentialSource === "vercel_connect") {
const connectorUid = connection.externalCredential?.connectorUid;
return (
<span
className={chipClass}
title={connectorUid ? `Credentials managed by Vercel Connect (${connectorUid})` : "Credentials managed by Vercel Connect"}
>
<Blocks className="h-3 w-3" />
via Vercel Connect
</span>
);
}
return null;
}
+4 -50
View File
@@ -25,8 +25,8 @@ vi.mock("@/api/tools", () => ({
listConnections: (companyId: string) => listConnectionsMock(companyId),
listAppsAttention: (companyId: string) => listAppsAttentionMock(companyId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
archiveConnection: (connectionId: string, options?: { confirmComposioChildren?: boolean }) =>
archiveConnectionMock(connectionId, options),
archiveConnection: (connectionId: string) =>
archiveConnectionMock(connectionId),
getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(),
startCloudConnectorEnrollment: (companyId: string, label?: string) => startCloudConnectorEnrollmentMock(companyId, label),
},
@@ -414,9 +414,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github", {
confirmComposioChildren: false,
});
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github");
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection deleted",
@@ -426,48 +424,6 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
);
});
it("confirms that deleting a Composio parent removes its child services", async () => {
listApplicationsMock.mockResolvedValue({
applications: [application({ id: "app-composio", name: "Composio" })],
});
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "composio-parent",
applicationId: "app-composio",
name: "Composio",
config: { sourceTemplateKey: "composio" },
}),
connection({
id: "composio-github",
applicationId: "app-composio",
name: "GitHub (via Composio)",
config: { provider: "composio", parentConnectionId: "composio-parent", toolkitSlug: "github" },
}),
],
});
await renderApps();
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Composio for the organization connection"]',
);
await act(async () => {
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.textContent).toContain("This also removes 1 connected service");
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Delete connection",
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("composio-parent", {
confirmComposioChildren: true,
});
});
it("reports the remaining active connections after deleting one", async () => {
listApplicationsMock.mockResolvedValue({
@@ -501,9 +457,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github-primary", {
confirmComposioChildren: false,
});
expect(archiveConnectionMock).toHaveBeenCalledWith("c-github-primary");
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection deleted",
+11 -14
View File
@@ -1,3 +1,4 @@
import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AppWindow, Cloud, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion, Trash2 } from "lucide-react";
@@ -33,8 +34,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { AppLogo } from "./AppLogo";
import { ConnectionProvenanceChip } from "./ComposioProvenanceChip";
import { composioChildParentConnectionId } from "./composio-services";
import { ConnectionProvenanceChip } from "./ConnectionProvenanceChip";
import {
appApplicationSourceSlug,
appDefinitionDarkLogoUrl,
@@ -57,7 +57,7 @@ const BROWSE_HREF = "/apps";
type StatusFilter = "all" | "attention";
type AppStatus = {
label: "Healthy" | "Needs attention" | "Paused" | "Not connected";
label: "Healthy" | "Needs attention" | "Paused" | "Not connected" | "Retired";
tone: "connected" | "attention" | "paused" | "not_connected";
};
@@ -81,6 +81,7 @@ type AppRow = {
* pill's `attention` tone and the row highlight are now the *same* predicate.
*/
function statusFor(application: ToolApplication, connections: ToolConnection[]): AppStatus {
if (connections.some(isRetiredComposioConnection)) return { label: "Retired", tone: "attention" };
if (connections.length === 0) {
return { label: "Not connected", tone: "not_connected" };
}
@@ -121,7 +122,7 @@ export function Connections() {
id: string;
appName: string;
remainingConnectionCount: number;
childConnectionCount: number;
} | null>(null);
useEffect(() => {
@@ -178,11 +179,9 @@ export function Connections() {
id: string;
appName: string;
remainingConnectionCount: number;
childConnectionCount: number;
}) =>
toolsApi.archiveConnection(target.id, {
confirmComposioChildren: target.childConnectionCount > 0,
}),
toolsApi.archiveConnection(target.id),
onSuccess: (_connection, target) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
@@ -413,6 +412,7 @@ export function Connections() {
const { application, connection, status } = row;
const attention = rowNeedsAttention(row);
const hint =
connection && isRetiredComposioConnection(connection) ? RETIRED_COMPOSIO_MESSAGE :
status.tone === "attention"
? connection?.authKind === "oauth"
? "Reconnect required — sign in again to restore access."
@@ -429,6 +429,7 @@ export function Connections() {
: `/apps/app/${application.id}/permissions`;
const actionLabel = !connection
? "Connect"
: connection && isRetiredComposioConnection(connection) ? "Review"
: status.tone === "attention"
? "Reconnect"
: "Permissions";
@@ -511,9 +512,7 @@ export function Connections() {
id: connection.id,
appName: application.name,
remainingConnectionCount: row.remainingAgentAvailableConnectionCount,
childConnectionCount: connections.filter(
(candidate) => composioChildParentConnectionId(candidate) === connection.id,
).length,
});
}}
>
@@ -549,9 +548,7 @@ export function Connections() {
Delete {connectionToDelete?.appName ?? "this"} connection?
</AlertDialogTitle>
<AlertDialogDescription>
{connectionToDelete && connectionToDelete.childConnectionCount > 0
? `This also removes ${connectionToDelete.childConnectionCount} connected ${connectionToDelete.childConnectionCount === 1 ? "service" : "services"} and takes agent access away immediately. The Composio key and child session credentials are deleted.`
: connectionToDelete && connectionToDelete.remainingConnectionCount > 0
{connectionToDelete && connectionToDelete.remainingConnectionCount > 0
? `This connection's saved credentials are deleted and agents lose access through it immediately. Agents can still use ${connectionToDelete.appName} through ${connectionToDelete.remainingConnectionCount} other active ${connectionToDelete.remainingConnectionCount === 1 ? "connection" : "connections"}.`
: "The saved credentials are deleted and agents lose access immediately. Connecting it again later needs a new sign-in or key."}
</AlertDialogDescription>
@@ -28,16 +28,6 @@ function renderDangerZone() {
return container;
}
function renderComposioDangerZone() {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(
<DangerZone appName="Composio" childConnectionCount={2} removing={false} onRemove={vi.fn()} />,
));
return container;
}
function expandDangerZone(node: HTMLDivElement) {
const trigger = Array.from(node.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Danger zone"));
@@ -132,9 +122,4 @@ describe("DangerZone", () => {
expect(text).toContain("requires a new sign-in or key");
});
it("names every child service that parent removal will take down", () => {
const node = renderComposioDangerZone();
expandDangerZone(node);
expect(node.textContent).toContain("2 connected services");
});
});
@@ -30,7 +30,6 @@ export function AdvancedPanel({
connection,
appName,
galleryEntry,
childConnectionCount,
removing,
onRemove,
onReplaced,
@@ -47,7 +46,6 @@ export function AdvancedPanel({
onRevokeIdentity,
}: Pick<AppDetailSectionProps, "connection" | "appName" | "galleryEntry"> & {
removing: boolean;
childConnectionCount?: number;
onRemove: () => void;
onReplaced: () => void;
canReplaceCredential?: boolean;
@@ -69,7 +67,6 @@ export function AdvancedPanel({
appName={appName}
connection={connection}
galleryEntry={galleryEntry}
childConnectionCount={childConnectionCount}
removing={removing}
onRemove={onRemove}
onReplaced={onReplaced}
@@ -427,7 +424,6 @@ export function DangerZone({
appName,
connection,
galleryEntry = null,
childConnectionCount = 0,
removing,
onRemove,
onReplaced,
@@ -446,7 +442,6 @@ export function DangerZone({
appName: string;
connection?: ToolConnection;
galleryEntry?: AppDefinition | null;
childConnectionCount?: number;
removing: boolean;
onRemove: () => void;
onReplaced?: () => void;
@@ -567,9 +562,7 @@ export function DangerZone({
<div>
<p className="text-sm font-medium text-foreground">Remove this app</p>
<p className="text-xs text-muted-foreground">
{childConnectionCount > 0
? `Deletes credentials for ${appName} and ${childConnectionCount} connected ${childConnectionCount === 1 ? "service" : "services"}.`
: `Deletes credentials for ${appName} and removes agent access. Reconnecting requires a new sign-in or key.`}
{`Deletes credentials for ${appName} and removes agent access. Reconnecting requires a new sign-in or key.`}
</p>
</div>
{confirming ? (
@@ -1,244 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ServicesList } from "./ServicesPanel";
import type { ComposioServiceRow } from "../composio-services";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({
// The row's "Manage" affordance only has to be a link with the right target;
// exercising the real router here would test the router, not the panel.
Link: ({ to, children, ...rest }: { to: string; children?: unknown }) => (
<a href={to} {...rest}>{children as never}</a>
),
}));
function act(callback: () => void) {
flushSync(callback);
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
container?.remove();
container = null;
root = null;
});
function row(over: Partial<ComposioServiceRow> & { toolkitSlug: string }): ComposioServiceRow {
return {
name: over.toolkitSlug,
description: null,
logoUrl: null,
state: "not_connected",
connectedAccountStatus: null,
childConnectionId: null,
toolCount: null,
noAuth: false,
...over,
};
}
function renderList(rows: ComposioServiceRow[], handlers: {
onConnect?: (row: ComposioServiceRow) => void;
onRecheck?: (row: ComposioServiceRow) => void;
onDisconnect?: (row: ComposioServiceRow) => void;
} = {}) {
container = document.createElement("div");
document.body.appendChild(container);
const renderRoot = createRoot(container);
root = renderRoot;
act(() => renderRoot.render(
<ServicesList
rows={rows}
busySlug={null}
onConnect={handlers.onConnect ?? vi.fn()}
onRecheck={handlers.onRecheck ?? vi.fn()}
onDisconnect={handlers.onDisconnect ?? vi.fn()}
/>,
));
return container;
}
function buttonLabelled(node: HTMLElement, label: string) {
return Array.from(node.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === label);
}
/**
* The three states the design asks for, plus the `attention` state Composio can
* report. Each state has to be distinguishable *and* offer the one action that
* moves it forward — a "Connected" row that still says "Connect" would be the
* bug this covers.
*/
describe("ServicesList row states", () => {
it("offers Connect on a not-connected service", () => {
const node = renderList([row({ toolkitSlug: "gmail", name: "Gmail", description: "Email" })]);
const text = node.textContent ?? "";
expect(text).toContain("Gmail");
expect(text).toContain("Not connected");
expect(buttonLabelled(node, "Connect")).toBeTruthy();
expect(buttonLabelled(node, "Disconnect")).toBeFalsy();
});
it("shows a pending service as waiting, with no Connect to click twice", () => {
const node = renderList([row({
toolkitSlug: "slack",
name: "Slack",
state: "pending",
connectedAccountStatus: "INITIALIZING",
})]);
const text = node.textContent ?? "";
expect(text).toContain("Pending");
expect(text).toContain("Waiting for Composio to confirm the connection.");
expect(buttonLabelled(node, "Connect")).toBeFalsy();
// Re-check is the only affordance, so an impatient user cannot mint a second link.
expect(node.querySelector('[aria-label="Check Slack again"]')).toBeTruthy();
});
it("shows a connected service with Manage and Disconnect", () => {
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
toolCount: 12,
})]);
const text = node.textContent ?? "";
expect(text).toContain("Connected");
expect(text).toContain("12 actions");
expect(buttonLabelled(node, "Connect")).toBeFalsy();
expect(buttonLabelled(node, "Disconnect")).toBeTruthy();
const manage = node.querySelector<HTMLAnchorElement>('a[href="/apps/child-1/permissions"]');
expect(manage?.textContent).toContain("Manage");
});
/**
* An expired credential is neither "connected" nor "still working": it needs the
* user. It therefore gets Reconnect *and* Disconnect, and says what Composio
* reported, so the reader is not left guessing why a green row went quiet.
*/
it("explains an unusable credential and offers Reconnect", () => {
const node = renderList([row({
toolkitSlug: "hubspot",
name: "HubSpot",
state: "attention",
connectedAccountStatus: "EXPIRED",
childConnectionId: "child-2",
})]);
const text = node.textContent ?? "";
expect(text).toContain("Needs attention");
expect(text).toContain("Composio reports this connection as expired");
expect(buttonLabelled(node, "Reconnect")).toBeTruthy();
expect(buttonLabelled(node, "Disconnect")).toBeTruthy();
});
it("renders every state together without collapsing any of them", () => {
const node = renderList([
row({ toolkitSlug: "github", name: "GitHub", state: "connected", childConnectionId: "c1" }),
row({ toolkitSlug: "slack", name: "Slack", state: "pending" }),
row({ toolkitSlug: "gmail", name: "Gmail" }),
row({ toolkitSlug: "hubspot", name: "HubSpot", state: "attention", connectedAccountStatus: "EXPIRED" }),
]);
expect(node.querySelectorAll("li")).toHaveLength(4);
const text = node.textContent ?? "";
for (const label of ["Connected", "Pending", "Not connected", "Needs attention"]) {
expect(text).toContain(label);
}
});
});
describe("ServicesList actions", () => {
it("asks to connect the row that was clicked", () => {
const onConnect = vi.fn();
const node = renderList([
row({ toolkitSlug: "gmail", name: "Gmail" }),
row({ toolkitSlug: "asana", name: "Asana" }),
], { onConnect });
const asanaRow = Array.from(node.querySelectorAll("li"))
.find((item) => item.textContent?.includes("Asana"));
const connect = Array.from(asanaRow!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Connect");
act(() => connect!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onConnect).toHaveBeenCalledTimes(1);
expect(onConnect.mock.calls[0]![0].toolkitSlug).toBe("asana");
});
/** Disconnect is destructive, so the row must not act on its own — it asks first. */
it("routes Disconnect through the caller's confirm step", () => {
const onDisconnect = vi.fn();
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
childConnectionId: "c1",
})], { onDisconnect });
act(() => buttonLabelled(node, "Disconnect")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onDisconnect).toHaveBeenCalledTimes(1);
expect(onDisconnect.mock.calls[0]![0].toolkitSlug).toBe("github");
});
it("disables the row's actions while it is busy", () => {
container = document.createElement("div");
document.body.appendChild(container);
const renderRoot = createRoot(container);
root = renderRoot;
act(() => renderRoot.render(
<ServicesList
rows={[row({ toolkitSlug: "gmail", name: "Gmail" })]}
busySlug="gmail"
onConnect={vi.fn()}
onRecheck={vi.fn()}
onDisconnect={vi.fn()}
/>,
));
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((button) => button.disabled)).toBe(true);
});
});
/**
* The tab must never render session or MCP credential material. The panel is
* given rows that deliberately have no such field, so this asserts the row type
* stays that way: if a future change threads a session URL or header into the
* row, this catches it at the render boundary.
*/
describe("ServicesList credential hygiene", () => {
it("renders no URL or bearer-looking text for a connected service", () => {
const node = renderList([row({
toolkitSlug: "github",
name: "GitHub",
state: "connected",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
})]);
const text = node.textContent ?? "";
expect(text).not.toMatch(/https?:\/\//);
expect(text).not.toMatch(/bearer/i);
expect(text).not.toMatch(/x-api-key/i);
// The Composio account id is an opaque handle, but it has no reason to be on screen.
expect(text).not.toContain("ca-");
});
});
@@ -1,435 +0,0 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
import { cn } from "@/lib/utils";
import { AppLogo } from "../AppLogo";
import { appTabHref } from "../app-tabs";
import {
composioServiceIsSettling,
composioServiceRows,
type ComposioServiceRow,
type ComposioServiceState,
} from "../composio-services";
/** How often a settling row is re-read while the user finishes authorizing in Composio. */
const PENDING_POLL_MS = 3_000;
/**
* The Services tab of a Composio connection (PAP-17865).
*
* Composio is a broker: this one connection's API key fronts every toolkit in the
* customer's Composio project. So this tab is a list of *services*, each with its
* own state, rather than the single-credential Setup tab every other app gets.
*
* Connecting a toolkit deliberately leaves Paperclip: the server mints a
* Composio-hosted Connect Link and the browser opens it in a new tab, so the
* third-party consent screen and any API key the toolkit needs are entered in
* Composio and never transit Paperclip. Because that happens out of band, the
* only way to learn the result is to re-read it — hence the poll below, which is
* what lets a row go pending→connected without a page reload.
*/
export function ServicesPanel({
connectionId,
appName,
}: {
connectionId: string;
appName: string;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [confirmDisconnect, setConfirmDisconnect] = useState<ComposioServiceRow | null>(null);
const [busySlug, setBusySlug] = useState<string | null>(null);
const servicesQuery = useQuery({
queryKey: queryKeys.tools.composioServices(connectionId),
queryFn: () => toolsApi.listComposioServices(connectionId),
enabled: !!connectionId,
// A row only settles when Composio finishes on the other tab, so poll while
// anything is in flight and stop as soon as nothing is. `refetchOnWindowFocus`
// (react-query's default) covers the common case of the user coming straight
// back after authorizing.
refetchInterval: (query) =>
composioServiceRows(query.state.data).some((row) => composioServiceIsSettling(row.state))
? PENDING_POLL_MS
: false,
});
const rows = composioServiceRows(servicesQuery.data);
/** A toolkit connecting adds a child connection, so the app-wide lists have to be re-read. */
const invalidateConnectionLists = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.composioServices(connectionId) });
queryClient.invalidateQueries({ queryKey: ["tools"] });
queryClient.invalidateQueries({ queryKey: ["apps"] });
};
const startConnect = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.startComposioServiceConnect(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: (link, row) => {
// The address comes back from Composio, so it is checked at the navigation
// boundary before the browser acts on it (same rule as PAP-17099).
const target = resolveAuthorizationTarget(link.redirect_url);
if (!target.ok) {
pushToast({ title: `Couldn't connect ${row.name}`, body: target.message, tone: "error" });
return;
}
// A new tab, not a top-level navigation: the user keeps this page — and its
// poll — alive while authorizing, which is what makes the row flip in place.
window.open(target.url, "_blank", "noopener,noreferrer");
pushToast({
title: `Finish connecting ${row.name} in Composio`,
body: "We opened Composio in a new tab. This list updates as soon as it reports back.",
tone: "info",
});
void servicesQuery.refetch();
},
onError: (error, row) =>
pushToast({
title: `Couldn't connect ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
const recheck = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.getComposioServiceStatus(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: () => invalidateConnectionLists(),
onError: (error, row) =>
pushToast({
title: `Couldn't check ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
const disconnect = useMutation({
mutationFn: (row: ComposioServiceRow) =>
toolsApi.disconnectComposioService(connectionId, row.toolkitSlug),
onMutate: (row) => setBusySlug(row.toolkitSlug),
onSuccess: (_result, row) => {
setConfirmDisconnect(null);
invalidateConnectionLists();
pushToast({
title: `${row.name} disconnected`,
body: `Agents can no longer use ${row.name}, and its credentials are deleted from Composio.`,
tone: "success",
});
},
onError: (error, row) =>
pushToast({
title: `Couldn't disconnect ${row.name}`,
body: error instanceof Error ? error.message : "Please try again.",
tone: "error",
}),
onSettled: () => setBusySlug(null),
});
if (servicesQuery.isLoading) {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading services from Composio, this may take a moment.
</div>
);
}
if (servicesQuery.isError) {
return (
<ServicesLoadError
message={servicesQuery.error instanceof Error ? servicesQuery.error.message : null}
onRetry={() => { void servicesQuery.refetch(); }}
/>
);
}
return (
<div className="space-y-6">
<ServicesIntro appName={appName} connectedCount={rows.filter((r) => r.state === "connected").length} />
{rows.length === 0 ? (
<ServicesEmptyState />
) : (
<ServicesList
rows={rows}
busySlug={busySlug}
onConnect={(row) => startConnect.mutate(row)}
onRecheck={(row) => recheck.mutate(row)}
onDisconnect={(row) => setConfirmDisconnect(row)}
/>
)}
{confirmDisconnect && (
<DisconnectDialog
row={confirmDisconnect}
pending={disconnect.isPending}
onCancel={() => setConfirmDisconnect(null)}
onConfirm={() => disconnect.mutate(confirmDisconnect)}
/>
)}
</div>
);
}
function ServicesIntro({ appName, connectedCount }: { appName: string; connectedCount: number }) {
return (
<div className="max-w-2xl space-y-1">
<h2 className="text-lg font-semibold">Services</h2>
<p className="text-sm leading-6 text-muted-foreground">
{appName} brokers these services. Connect one and it becomes its own app in Paperclip, which
you then give to agents on its Permissions tab.
{connectedCount > 0 && (
<>
{" "}
<span className="font-medium text-foreground">
{connectedCount} {connectedCount === 1 ? "service is" : "services are"} connected.
</span>
</>
)}
</p>
</div>
);
}
function ServicesEmptyState() {
return (
<div className="rounded-xl border border-border bg-card p-6">
<p className="text-sm font-medium">No services available yet</p>
<p className="mt-1 max-w-xl text-sm text-muted-foreground">
This Composio project has no toolkits Paperclip can offer. Add a toolkit and an auth
configuration in Composio, then check back.
</p>
</div>
);
}
function ServicesLoadError({ message, onRetry }: { message: string | null; onRetry: () => void }) {
return (
<div className="space-y-3 py-8">
<p className="text-sm text-destructive">
{message ?? "Couldn’t load services from Composio."}
</p>
<Button size="sm" variant="outline" onClick={onRetry}>Try again</Button>
</div>
);
}
/**
* The toolkit list. Split out from the panel so every row state can be rendered —
* and screenshotted — without a server.
*/
export function ServicesList({
rows,
busySlug,
onConnect,
onRecheck,
onDisconnect,
}: {
rows: ComposioServiceRow[];
busySlug: string | null;
onConnect: (row: ComposioServiceRow) => void;
onRecheck: (row: ComposioServiceRow) => void;
onDisconnect: (row: ComposioServiceRow) => void;
}) {
return (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{rows.map((row) => (
<ServiceRow
key={row.toolkitSlug}
row={row}
busy={busySlug === row.toolkitSlug}
onConnect={onConnect}
onRecheck={onRecheck}
onDisconnect={onDisconnect}
/>
))}
</ul>
);
}
export function ServiceRow({
row,
busy,
onConnect,
onRecheck,
onDisconnect,
}: {
row: ComposioServiceRow;
busy: boolean;
onConnect: (row: ComposioServiceRow) => void;
onRecheck: (row: ComposioServiceRow) => void;
onDisconnect: (row: ComposioServiceRow) => void;
}) {
return (
<li className="flex flex-wrap items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50">
<AppLogo name={row.name} logoUrl={row.logoUrl} size={32} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{row.name}</span>
<ServiceStateBadge state={row.state} />
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{serviceDetailLine(row)}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{row.state === "connected" && row.childConnectionId && (
<Button asChild size="sm" variant="ghost">
<Link to={appTabHref(row.childConnectionId, "permissions")}>Manage</Link>
</Button>
)}
{row.state === "pending" && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onRecheck(row)}
aria-label={`Check ${row.name} again`}
>
{busy
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
</Button>
)}
{row.state === "not_connected" ? (
<Button size="sm" disabled={busy} onClick={() => onConnect(row)}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : (
<>
Connect
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</>
)}
</Button>
) : row.state === "attention" ? (
<>
<Button size="sm" disabled={busy} onClick={() => onConnect(row)}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Reconnect"}
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDisconnect(row)}>
Disconnect
</Button>
</>
) : row.state === "connected" ? (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onDisconnect(row)}>
Disconnect
</Button>
) : null}
</div>
</li>
);
}
/**
* The detail line under a service name. It answers "why is this row in this
* state", which for `pending` and `attention` is the only place Composio's own
* explanation can appear.
*/
function serviceDetailLine(row: ComposioServiceRow): string {
const toolCount = row.toolCount !== null
? `${row.toolCount} ${row.toolCount === 1 ? "action" : "actions"}`
: null;
if (row.state === "pending") {
return "Waiting for Composio to confirm the connection.";
}
if (row.state === "attention") {
return row.connectedAccountStatus
? `Composio reports this connection as ${row.connectedAccountStatus.toLowerCase()}. Reconnect to fix it.`
: "This connection is no longer usable. Reconnect to fix it.";
}
if (row.state === "connected") {
return [toolCount, "available to agents you install it for"].filter(Boolean).join(" · ");
}
return [
row.description,
toolCount,
row.noAuth ? "No sign-in needed" : null,
].filter(Boolean).join(" · ") || "Not connected";
}
const STATE_LABEL: Record<ComposioServiceState, string> = {
not_connected: "Not connected",
pending: "Pending",
connected: "Connected",
attention: "Needs attention",
};
/**
* Row state, in the same visual language as the connection status badge in the
* app header — a reader should not have to learn two palettes for "connected".
*/
function ServiceStateBadge({ state }: { state: ComposioServiceState }) {
const klass: Record<ComposioServiceState, string> = {
connected: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
pending: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
attention: "border-destructive/40 bg-destructive/10 text-destructive",
not_connected: "border-border bg-muted text-muted-foreground",
};
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
klass[state],
)}
>
{state === "connected" && <Check className="h-3 w-3" />}
{state === "pending" && <Loader2 className="h-3 w-3 animate-spin" />}
{state === "attention" && <AlertTriangle className="h-3 w-3" />}
{STATE_LABEL[state]}
</span>
);
}
function DisconnectDialog({
row,
pending,
onCancel,
onConfirm,
}: {
row: ComposioServiceRow;
pending: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect {row.name}?</AlertDialogTitle>
<AlertDialogDescription>
This removes {row.name} from Paperclip and deletes its credentials in Composio. Agents
using its actions lose them immediately. Connecting it again needs a new sign-in.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending} autoFocus>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={pending}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
>
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : `Disconnect ${row.name}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+1 -23
View File
@@ -1,34 +1,12 @@
import { Blocks, Inbox, ShieldCheck } from "lucide-react";
import { Inbox, ShieldCheck } from "lucide-react";
export const APP_TABS = [
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "services", label: "Services", icon: Blocks },
{ key: "review", label: "Review", icon: Inbox },
] as const;
export type AppTabKey = (typeof APP_TABS)[number]["key"];
/**
* Tabs hidden for an application that has no live connection (the
* `AppNotConnected` shell). Services lists the toolkits behind a broker's API
* key, which there is nothing to read without a live connection.
*/
export const CONNECTED_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>([
"services",
]);
/**
* Tabs that only make sense for a connection that brokers other services
* (PAP-17865).
*
* Composio is the only broker today: one API-key connection fronts many toolkits,
* so it is the only connection with per-service state to manage. Every other app
* *is* the service, so a Services tab there would be an empty page. This is a
* separate set from `CONNECTED_ONLY_APP_TABS` because the two conditions compose —
* a broker tab needs both a live connection and a broker.
*/
export const BROKER_ONLY_APP_TABS: ReadonlySet<AppTabKey> = new Set<AppTabKey>(["services"]);
export function appTabHref(connectionId: string, tab: AppTabKey): string {
return `/apps/${connectionId}/${tab}`;
}
-237
View File
@@ -1,237 +0,0 @@
import { describe, expect, it } from "vitest";
import {
composioChildParentConnectionId,
composioChildToolkitSlug,
composioServiceIsSettling,
composioServiceRowFromStatus,
composioServiceRows,
composioServiceStateFor,
isComposioBrokerConnection,
type ComposioServicesResponse,
} from "./composio-services";
import { BROKER_ONLY_APP_TABS, CONNECTED_ONLY_APP_TABS } from "./app-tabs";
function toolkit(slug: string, over: Record<string, unknown> = {}) {
return {
slug,
name: slug.toUpperCase(),
meta: { description: `${slug} desc`, logo: `https://logo/${slug}.png`, tools_count: 3 },
...over,
};
}
function listResponse(
services: ComposioServicesResponse["services"],
): ComposioServicesResponse {
return { parentConnectionId: "parent-1", userId: "paperclip:company-1", services };
}
describe("composioServiceStateFor", () => {
it("reads ACTIVE as connected regardless of casing", () => {
expect(composioServiceStateFor("ACTIVE")).toBe("connected");
expect(composioServiceStateFor("active")).toBe("connected");
});
it("reads a missing account as not connected", () => {
expect(composioServiceStateFor(null)).toBe("not_connected");
expect(composioServiceStateFor(undefined)).toBe("not_connected");
expect(composioServiceStateFor(" ")).toBe("not_connected");
});
it("reads an unusable credential as attention, not pending", () => {
for (const status of ["EXPIRED", "INACTIVE", "DISABLED", "FAILED", "REVOKED", "ERROR"]) {
expect(composioServiceStateFor(status)).toBe("attention");
}
});
/**
* The fallback direction matters: an unrecognized status must read as "still
* working on it" so a status Composio adds later shows a spinner the poll will
* clear, rather than a wrong terminal verdict the user cannot act on.
*/
it("treats an unknown in-flight status as pending", () => {
expect(composioServiceStateFor("INITIALIZING")).toBe("pending");
expect(composioServiceStateFor("INITIATED")).toBe("pending");
expect(composioServiceStateFor("SOME_FUTURE_STATUS")).toBe("pending");
});
});
describe("composioServiceRows", () => {
it("renders all three design states from one payload", () => {
const rows = composioServiceRows(listResponse([
{
toolkit: toolkit("gmail"),
status: "not_connected",
connectedAccountId: null,
connectedAccountStatus: null,
childConnectionId: null,
},
{
toolkit: toolkit("github"),
status: "connected",
connectedAccountId: "ca-1",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
},
{
toolkit: toolkit("slack"),
status: "pending",
connectedAccountId: "ca-2",
connectedAccountStatus: "INITIALIZING",
childConnectionId: null,
},
]));
expect(rows.map((row) => [row.toolkitSlug, row.state])).toEqual([
["github", "connected"],
["slack", "pending"],
["gmail", "not_connected"],
]);
});
it("carries the child connection id so a connected row can link to it", () => {
const [row] = composioServiceRows(listResponse([{
toolkit: toolkit("github"),
status: "connected",
connectedAccountId: "ca-1",
connectedAccountStatus: "ACTIVE",
childConnectionId: "child-1",
}]));
expect(row!.childConnectionId).toBe("child-1");
expect(row!.logoUrl).toBe("https://logo/github.png");
expect(row!.toolCount).toBe(3);
});
/**
* The server's own `status` field only knows connected/pending/not_connected, so
* an expired account arrives labelled `pending`. Deriving state from the account
* status instead is what keeps that row from showing a spinner forever.
*/
it("classifies an expired account as attention even when the server said pending", () => {
const [row] = composioServiceRows(listResponse([{
toolkit: toolkit("github"),
status: "pending",
connectedAccountId: "ca-1",
connectedAccountStatus: "EXPIRED",
childConnectionId: "child-1",
}]));
expect(row!.state).toBe("attention");
});
it("sorts attention first, then connected, then pending, then the alphabetical tail", () => {
const rows = composioServiceRows(listResponse([
{ toolkit: toolkit("zoom"), status: "not_connected", connectedAccountId: null, connectedAccountStatus: null, childConnectionId: null },
{ toolkit: toolkit("asana"), status: "not_connected", connectedAccountId: null, connectedAccountStatus: null, childConnectionId: null },
{ toolkit: toolkit("slack"), status: "pending", connectedAccountId: "c", connectedAccountStatus: "INITIALIZING", childConnectionId: null },
{ toolkit: toolkit("github"), status: "connected", connectedAccountId: "c", connectedAccountStatus: "ACTIVE", childConnectionId: "x" },
{ toolkit: toolkit("gmail"), status: "connected", connectedAccountId: "c", connectedAccountStatus: "EXPIRED", childConnectionId: "y" },
]));
expect(rows.map((row) => row.toolkitSlug)).toEqual(["gmail", "github", "slack", "asana", "zoom"]);
});
it("tolerates a missing payload and missing toolkit metadata", () => {
expect(composioServiceRows(undefined)).toEqual([]);
const [row] = composioServiceRows(listResponse([{
toolkit: { slug: "bare", name: "" },
status: "not_connected",
connectedAccountId: null,
connectedAccountStatus: null,
childConnectionId: null,
}]));
expect(row!.name).toBe("bare");
expect(row!.description).toBeNull();
expect(row!.logoUrl).toBeNull();
expect(row!.toolCount).toBeNull();
});
});
describe("composioServiceRowFromStatus", () => {
/**
* The poll endpoint answers a different shape than the list. Normalizing both
* to one row is what lets a pending row flip to connected in place.
*/
it("normalizes the poll payload into the same row the list produces", () => {
const row = composioServiceRowFromStatus({
toolkit: toolkit("github"),
account: { id: "ca-1", status: "ACTIVE" },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
child: { id: "child-9" } as any,
});
expect(row.state).toBe("connected");
expect(row.childConnectionId).toBe("child-9");
expect(row.toolkitSlug).toBe("github");
});
it("reads no account as not connected", () => {
const row = composioServiceRowFromStatus({
toolkit: toolkit("gmail"),
account: null,
child: null,
});
expect(row.state).toBe("not_connected");
expect(row.childConnectionId).toBeNull();
});
});
describe("composioServiceIsSettling", () => {
it("keeps polling only while a row is pending", () => {
expect(composioServiceIsSettling("pending")).toBe(true);
expect(composioServiceIsSettling("connected")).toBe(false);
expect(composioServiceIsSettling("not_connected")).toBe(false);
// An unusable credential needs the user, not another poll.
expect(composioServiceIsSettling("attention")).toBe(false);
});
});
describe("connection provenance", () => {
const broker = { config: { sourceTemplateKey: "composio" } };
const child = {
config: { provider: "composio", parentConnectionId: "parent-1", toolkitSlug: "github" },
};
it("recognizes the broker connection", () => {
expect(isComposioBrokerConnection(broker)).toBe(true);
});
/** The two kinds must not overlap, or a child would grow its own Services tab. */
it("does not mistake a toolkit child for the broker", () => {
expect(isComposioBrokerConnection(child)).toBe(false);
expect(composioChildToolkitSlug(broker)).toBeNull();
});
it("ignores unrelated apps", () => {
expect(isComposioBrokerConnection({ config: { sourceTemplateKey: "github" } })).toBe(false);
expect(isComposioBrokerConnection({ config: {} })).toBe(false);
expect(isComposioBrokerConnection(null)).toBe(false);
expect(composioChildToolkitSlug({ config: { sourceTemplateKey: "github" } })).toBeNull();
expect(composioChildToolkitSlug(null)).toBeNull();
});
it("reads a child's toolkit and parent for the provenance chip", () => {
expect(composioChildToolkitSlug(child)).toBe("github");
expect(composioChildParentConnectionId(child)).toBe("parent-1");
});
it("reads a child with no parent link as unlinked rather than throwing", () => {
const orphan = { config: { provider: "composio", toolkitSlug: "github" } };
expect(composioChildToolkitSlug(orphan)).toBe("github");
expect(composioChildParentConnectionId(orphan)).toBeNull();
});
});
describe("Services tab visibility", () => {
/**
* Services needs both gates: a live connection to read an API key from, and a
* broker to have services at all.
*/
it("is gated on both a connection and a broker", () => {
expect(CONNECTED_ONLY_APP_TABS.has("services")).toBe(true);
expect(BROKER_ONLY_APP_TABS.has("services")).toBe(true);
});
it("does not gate the ordinary tabs behind broker-only", () => {
for (const tab of ["review", "permissions"] as const) {
expect(BROKER_ONLY_APP_TABS.has(tab)).toBe(false);
}
});
});
-225
View File
@@ -1,225 +0,0 @@
import type { ToolConnection } from "@paperclipai/shared";
/**
* Reading Composio's service endpoints for the Services tab (PAP-17865).
*
* A Composio connection is a gateway to many services, not a service itself, so
* its detail page lists *toolkits* (GitHub, Gmail, …) and each row has its own
* connection state. The server (PAP-17866) answers two differently shaped
* payloads for the same row:
*
* - the list (`GET …/services`) already classifies each row, and
* - the poll (`GET …/services/:slug/status`) returns the raw Composio toolkit,
* connected account, and child connection, leaving the classification open.
*
* Rather than teach the panel both shapes, both are normalized to `ComposioServiceRow`
* here. That keeps the "what state is this row in" decision in one testable place
* instead of spread across render branches, and it means a later contract cleanup
* on the server only has to move these two adapters.
*/
/** Raw Composio toolkit as the server forwards it. Only the display fields are read. */
export interface ComposioToolkitPayload {
slug: string;
name: string;
no_auth?: boolean;
auth_schemes?: string[];
composio_managed_auth_schemes?: string[];
meta?: {
description?: string | null;
logo?: string | null;
tools_count?: number;
} | null;
}
/** `GET /tool-connections/:id/services` */
export interface ComposioServicesResponse {
parentConnectionId: string;
/** Composio `user_id` these accounts belong to (`paperclip:{companyId}`). Not a secret. */
userId: string;
services: Array<{
toolkit: ComposioToolkitPayload;
status: string;
connectedAccountId: string | null;
connectedAccountStatus: string | null;
childConnectionId: string | null;
}>;
}
/** `GET /tool-connections/:id/services/:toolkitSlug/status` — the poll target. */
export interface ComposioServiceStatusResponse {
toolkit: ComposioToolkitPayload;
account: { id: string; status: string; status_reason?: string | null } | null;
child: ToolConnection | null;
}
/** `POST /tool-connections/:id/services/:toolkitSlug/connect` — Composio-hosted link. */
export interface ComposioConnectLinkResponse {
toolkitSlug: string;
authConfigId: string;
redirect_url: string;
expires_at?: string | null;
connected_account_id?: string | null;
}
/** `DELETE /tool-connections/:id/services/:toolkitSlug` */
export interface ComposioDisconnectResponse {
toolkitSlug: string;
disconnectedAccountIds: string[];
removedChildIds: string[];
}
/**
* The three states the design asks for, plus `attention`.
*
* `attention` is not a fourth visual language — it reuses the same row as
* `connected` with a warning tone. It exists because Composio can report an
* account that is neither absent nor usable (expired, revoked upstream), and
* collapsing that into `pending` would show a spinner that never resolves.
*/
export type ComposioServiceState = "not_connected" | "pending" | "connected" | "attention";
export interface ComposioServiceRow {
toolkitSlug: string;
name: string;
description: string | null;
logoUrl: string | null;
state: ComposioServiceState;
/** Raw Composio account status (`ACTIVE`, `INITIALIZING`, `EXPIRED`, …), for the detail line. */
connectedAccountStatus: string | null;
/** The Paperclip child connection for this toolkit, once one exists. */
childConnectionId: string | null;
/** Number of tools the toolkit exposes, when Composio reports it. */
toolCount: number | null;
/** True when Composio needs no credential for this toolkit. */
noAuth: boolean;
}
/** Composio account statuses that mean "there is a credential and it works". */
const ACTIVE_STATUS = "ACTIVE";
/**
* Statuses that mean a credential exists but cannot be used. Anything else
* unfinished (`INITIALIZING`, `INITIATED`, …) is treated as still in flight, so a
* status Composio adds later degrades to `pending` rather than to a wrong verdict.
*/
const FAILED_STATUSES = new Set(["EXPIRED", "INACTIVE", "DISABLED", "FAILED", "REVOKED", "ERROR"]);
export function composioServiceStateFor(accountStatus: string | null | undefined): ComposioServiceState {
const status = accountStatus?.trim().toUpperCase();
if (!status) return "not_connected";
if (status === ACTIVE_STATUS) return "connected";
if (FAILED_STATUSES.has(status)) return "attention";
return "pending";
}
function toolkitDisplay(toolkit: ComposioToolkitPayload) {
return {
toolkitSlug: toolkit.slug,
name: toolkit.name || toolkit.slug,
description: toolkit.meta?.description?.trim() || null,
logoUrl: toolkit.meta?.logo?.trim() || null,
toolCount: typeof toolkit.meta?.tools_count === "number" ? toolkit.meta.tools_count : null,
noAuth: toolkit.no_auth === true,
};
}
/** Normalizes the list payload. Trusts the account status, not the server's `status` string. */
export function composioServiceRows(response: ComposioServicesResponse | undefined): ComposioServiceRow[] {
return (response?.services ?? [])
.map((service) => ({
...toolkitDisplay(service.toolkit),
// `connectedAccountStatus` is the ground truth: the server's `status` field
// only distinguishes connected/pending/not_connected, so reading it would
// lose the difference between "still authorizing" and "expired".
state: composioServiceStateFor(service.connectedAccountStatus),
connectedAccountStatus: service.connectedAccountStatus,
childConnectionId: service.childConnectionId,
}))
.sort(compareServiceRows);
}
/** Normalizes the poll payload into the same row the list produced. */
export function composioServiceRowFromStatus(
response: ComposioServiceStatusResponse,
): ComposioServiceRow {
return {
...toolkitDisplay(response.toolkit),
state: composioServiceStateFor(response.account?.status),
connectedAccountStatus: response.account?.status ?? null,
childConnectionId: response.child?.id ?? null,
};
}
const STATE_ORDER: Record<ComposioServiceState, number> = {
attention: 0,
connected: 1,
pending: 2,
not_connected: 3,
};
/**
* Rows that need the reader's attention first, then connected services, then the
* long alphabetical tail of everything connectable. A Composio project can expose
* hundreds of toolkits, so the handful that are actually wired up must not be lost
* in it.
*/
function compareServiceRows(a: ComposioServiceRow, b: ComposioServiceRow): number {
const byState = STATE_ORDER[a.state] - STATE_ORDER[b.state];
return byState !== 0 ? byState : a.name.localeCompare(b.name);
}
/** True while a row should keep polling — the Connect Link is open and unresolved. */
export function composioServiceIsSettling(state: ComposioServiceState): boolean {
return state === "pending";
}
/**
* Telling the two kinds of Composio connection apart.
*
* The server (PAP-17866) stamps each kind with a different `config` marker, and
* they are mutually exclusive:
*
* - the broker carries the gallery's `sourceTemplateKey: "composio"`, and
* - a toolkit child carries `provider: "composio"` plus its parent and slug.
*
* Both predicates read those markers rather than the connection's name or
* transport, because a child is an ordinary `mcp_remote` connection and its name
* is user-renameable.
*/
function configOf(connection: { config?: Record<string, unknown> | null }): Record<string, unknown> {
return connection.config ?? {};
}
/** The parent: a Composio API-key connection whose Services tab lists toolkits. */
export function isComposioBrokerConnection(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): boolean {
if (!connection) return false;
const config = configOf(connection);
return config.sourceTemplateKey === "composio" && config.connectionMethodKey !== "mcp" && config.provider !== "composio";
}
/**
* The toolkit slug a child connection serves, or `null` if it is not a Composio
* child. Drives the "via Composio" provenance chip.
*/
export function composioChildToolkitSlug(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): string | null {
if (!connection) return null;
const config = configOf(connection);
if (config.provider !== "composio") return null;
const slug = config.toolkitSlug;
return typeof slug === "string" && slug.trim() ? slug.trim() : null;
}
/** The broker a child hangs off, for linking the chip back to the Services tab. */
export function composioChildParentConnectionId(
connection: { config?: Record<string, unknown> | null } | null | undefined,
): string | null {
if (!connection) return null;
const config = configOf(connection);
if (config.provider !== "composio") return null;
const parentId = config.parentConnectionId;
return typeof parentId === "string" && parentId.trim() ? parentId.trim() : null;
}
@@ -429,7 +429,7 @@ function AgentMailJourney({
connections: [],
chatEndpoints: [],
}}
allConnections={[]}
userProfileById={new Map()}
onNavigate={() => go(connected ? "permissions" : "access")}
onRequestRemove={() => {}}
@@ -76,7 +76,7 @@ function Journey({ screen = "catalog" }: { screen?: Screen }) {
<Routes>
<Route path="/:companyPrefix/apps" element={<div role="list" aria-label="Connectors"><ConnectorCard
row={{ key: "imessage-photon", slug: "imessage-photon", name: "iMessage Photon", description: "Message an agent from Apple Messages. Send photos and keep one task conversation.", brandKey: "imessage-photon", entry: null, applications: [], connections: [], chatEndpoints: [] }}
allConnections={[]} userProfileById={new Map()} chatConnectorsEnabled
userProfileById={new Map()} chatConnectorsEnabled
onNavigate={() => navigate(setupUrl)} onRequestRemove={() => {}}
/></div>} />
<Route path="/:companyPrefix/apps/chat/new" element={<ChatEndpointSetup />} />