From 4329b62cee9a5a42cab9432b7e739da7b3dfe122 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 04:26:22 -0700 Subject: [PATCH] CL-6492: resolve list_connections status from real credentials, not the inference catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_connections resolved every connector's status through listConnectedProviders, which answers from the model catalog (modelProvider) — a table persistConnectorCredential only seeds for inference providers. A verified GitHub/Linear/Notion/Sentry/Exa credential never reaches that table, so the tool always reported those connectors "Not connected" even when a live credential existed, and the agent correctly declined work it could actually do. createWorkflowConnectionRoutes' /connections route now resolves each CONNECTOR_REGISTRY entry through resolveCredentialRequirement (@intx/db) keyed by the connector's registry id as its provider name — the same resolution buildCredentialDelivery uses at agent-launch time to decide whether a tool actually gets a credential. This covers an inference provider and a tool connector the same way, with no per-kind branch at the call site. --- apps/hub/src/index.ts | 15 +++- .../src/workflow-connection-routes.test.ts | 71 ++++++++++++++++--- .../src/workflow-connection-routes.ts | 57 +++++++++------ 3 files changed, 111 insertions(+), 32 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index fe4054bf8..e70fed21d 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -16,6 +16,7 @@ import { createWorkflowRunDispatchStore, listVisibleOfferings, resolveCredentialByName, + resolveCredentialRequirement, } from "@intx/db"; import { asset as assetTable, @@ -2234,8 +2235,18 @@ export async function createHub(config: HubConfig) { "/api/workflow-connections", createWorkflowConnectionRoutes({ authenticator: createWorkflowRunAuthenticator({ db }), - listConnectedProviders: (tenantId) => - listConnectedProviders(db, tenantId), + // The same resolution `buildCredentialDelivery` uses at agent-launch + // time to decide whether a tool actually gets a credential — so + // `list_connections` reports exactly what an agent could really use, + // for an inference provider and a tool connector alike (CL-6492). + isConnectorConnected: async (tenantId, connectorId) => + (await resolveCredentialRequirement( + db, + tenantId, + { providerName: connectorId, source: "tenant" }, + null, + null, + )) !== null, listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), }), ); diff --git a/packages/connections/src/workflow-connection-routes.test.ts b/packages/connections/src/workflow-connection-routes.test.ts index 44c61e716..a11c09b92 100644 --- a/packages/connections/src/workflow-connection-routes.test.ts +++ b/packages/connections/src/workflow-connection-routes.test.ts @@ -24,7 +24,7 @@ describe("createWorkflowConnectionRoutes", () => { test("rejects a call with a missing or unrecognized sidecar bearer token / run address", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - listConnectedProviders: async () => [], + isConnectorConnected: async () => false, }); const response = await app.request("/connections", { @@ -39,13 +39,22 @@ describe("createWorkflowConnectionRoutes", () => { expect(body.error.code).toBe("unauthorized"); }); - test("returns every registry entry with its live connected status for the authenticated tenant", async () => { + test("reports a non-inference connector (GitHub) connected once it has a real, verified credential", async () => { + // CL-6492: `list_connections` used to resolve every connector's + // status from the inference-only model catalog, so a verified + // GitHub PAT (never seeded into that catalog) always read "not + // connected" — the agent would refuse work it could actually do. let seenTenantId: string | undefined; + let seenConnectorId: string | undefined; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - listConnectedProviders: async (tenantId) => { - seenTenantId = tenantId; - return ["granola", "exa"]; + isConnectorConnected: async (tenantId, connectorId) => { + if (connectorId === "github") { + seenTenantId = tenantId; + seenConnectorId = connectorId; + return true; + } + return false; }, }); @@ -58,12 +67,32 @@ describe("createWorkflowConnectionRoutes", () => { expect(response.status).toBe(200); expect(seenTenantId).toBe("tenant_1"); + expect(seenConnectorId).toBe("github"); + const body = (await response.json()) as { + data: { id: string; displayName: string; connected: boolean }[]; + }; + const github = body.data.find((entry) => entry.id === "github"); + expect(github?.connected).toBe(true); + }); + + test("still reports an unconnected connector as not connected", async () => { + const app = createWorkflowConnectionRoutes({ + authenticator: fakeAuthenticator(), + isConnectorConnected: async () => false, + }); + + const response = await app.request("/connections", { + headers: { + authorization: `Bearer ${VALID_TOKEN}`, + "x-workflow-run-address": VALID_ADDRESS, + }, + }); + + expect(response.status).toBe(200); const body = (await response.json()) as { data: { id: string; displayName: string; connected: boolean }[]; }; - const granola = body.data.find((entry) => entry.id === "granola"); const linear = body.data.find((entry) => entry.id === "linear"); - expect(granola?.connected).toBe(true); expect(linear?.connected).toBe(false); // The full registry, not a filtered subset — an unconnected // connector still gets a card the client can render a "not @@ -71,13 +100,35 @@ describe("createWorkflowConnectionRoutes", () => { expect(body.data.length).toBeGreaterThan(2); }); - test("never calls listConnectedProviders before authentication succeeds", async () => { + test("keeps reporting an inference provider (Anthropic) connected", async () => { + const app = createWorkflowConnectionRoutes({ + authenticator: fakeAuthenticator(), + isConnectorConnected: async (_tenantId, connectorId) => + connectorId === "anthropic", + }); + + const response = await app.request("/connections", { + headers: { + authorization: `Bearer ${VALID_TOKEN}`, + "x-workflow-run-address": VALID_ADDRESS, + }, + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { id: string; displayName: string; connected: boolean }[]; + }; + const anthropic = body.data.find((entry) => entry.id === "anthropic"); + expect(anthropic?.connected).toBe(true); + }); + + test("never calls isConnectorConnected before authentication succeeds", async () => { let called = false; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - listConnectedProviders: async () => { + isConnectorConnected: async () => { called = true; - return []; + return false; }, }); diff --git a/packages/connections/src/workflow-connection-routes.ts b/packages/connections/src/workflow-connection-routes.ts index acec690b7..ac32bcb27 100644 --- a/packages/connections/src/workflow-connection-routes.ts +++ b/packages/connections/src/workflow-connection-routes.ts @@ -13,11 +13,25 @@ // the tenant every read is scoped to come from the authenticated run // alone. // +// `GET /connections` reports a connector connected through the same +// resolution an actual agent launch uses to deliver its credential — +// `@intx/db`'s `resolveCredentialRequirement`, keyed on the connector's +// registry `id` as its provider name (CL-6492). This is deliberately NOT +// `@corbits/chat`'s `listConnectedProviders`: that lister answers from the +// model catalog (`modelProvider`, seeded only for inference providers by +// `persistConnectorCredential`'s `seedCatalog` step), so every +// non-inference connector — GitHub, Linear, Notion, Sentry, Exa — reads +// "not connected" there even with a live, verified credential. Resolving +// through `resolveCredentialRequirement` instead covers both kinds +// uniformly: an inference provider's credential (however it was named — +// `persistConnectorCredential`'s own `displayName` row, or an onboarding +// seed's `-default` row) and a tool connector's credential both +// resolve the same way, by `provider.name = descriptor.id` and an active +// `credential` row against it, with no per-connector-kind branch here. +// // No `requireGrant` on `GET /connections`, unlike `./routes.ts`'s -// tenant-session routes: this endpoint is read-only (it derives its -// answer from `CONNECTOR_REGISTRY`, a static catalog, and -// `listConnectedProviders`, itself a read) and mutates nothing, so there -// is no write to gate. The companion `request_connection` tool +// tenant-session routes: this endpoint is read-only and mutates nothing, +// so there is no write to gate. The companion `request_connection` tool // (`@corbits/connections-tools`) needs no route at all — it validates a // connector id against the same `CONNECTOR_REGISTRY` and builds a // deep-link string, entirely in-process, never touching the network or @@ -69,13 +83,15 @@ export type CreateWorkflowConnectionRoutesDeps = { /** A port, not a raw `db` handle — keeps this package decoupled from * the credentials schema, mirroring `@corbits/routines`' routes.ts * taking ports rather than reaching for database access directly. - * `apps/hub` supplies `packages/chat/src/inference-preferences.ts`'s - * `listConnectedProviders(db, tenantId)`, curried over `db`, the same - * function `listMyraUsableToolPackages` (apps/hub/src/index.ts) is - * built on. */ - readonly listConnectedProviders: ( + * `apps/hub` supplies `@intx/db`'s `resolveCredentialRequirement`, + * curried over `db` and the `"tenant"` source (the same resolution + * `buildCredentialDelivery` uses at agent-launch time to decide whether + * a tool actually gets a credential), so this route reports exactly + * what an agent could really use — never a catalog-derived guess. */ + readonly isConnectorConnected: ( tenantId: string, - ) => Promise; + connectorId: string, + ) => Promise; /** Backs `GET /mcp-servers` (`@corbits/mcp-tools`' `mcp_list_servers`): * every `mcp:` server this tenant has connected. `apps/hub` * supplies `@workbench/connections`' own `listMcpServerConnections` @@ -118,17 +134,18 @@ export function createWorkflowConnectionRoutes( app.get("/connections", async (c) => { const scope = c.get("workflowConnectionScope"); - const connectedIds = new Set( - await deps.listConnectedProviders(scope.tenantId), + const descriptors = Object.values(CONNECTOR_REGISTRY); + const connections: ConnectionSummary[] = await Promise.all( + descriptors.map(async (descriptor) => ({ + id: descriptor.id, + displayName: descriptor.displayName, + docsUrl: descriptor.docsUrl, + connected: await deps.isConnectorConnected( + scope.tenantId, + descriptor.id, + ), + })), ); - const connections: ConnectionSummary[] = Object.values( - CONNECTOR_REGISTRY, - ).map((descriptor) => ({ - id: descriptor.id, - displayName: descriptor.displayName, - docsUrl: descriptor.docsUrl, - connected: connectedIds.has(descriptor.id), - })); return c.json({ data: connections }, 200); });