Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createWorkflowRunDispatchStore,
listVisibleOfferings,
resolveCredentialByName,
resolveCredentialRequirement,
} from "@intx/db";
import {
asset as assetTable,
Expand Down Expand Up @@ -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),
}),
);
Expand Down
71 changes: 61 additions & 10 deletions packages/connections/src/workflow-connection-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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;
},
});

Expand All @@ -58,26 +67,68 @@ 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
// connected" state for.
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;
},
});

Expand Down
57 changes: 37 additions & 20 deletions packages/connections/src/workflow-connection-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-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
Expand Down Expand Up @@ -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<readonly string[]>;
connectorId: string,
) => Promise<boolean>;
/** Backs `GET /mcp-servers` (`@corbits/mcp-tools`' `mcp_list_servers`):
* every `mcp:<slug>` server this tenant has connected. `apps/hub`
* supplies `@workbench/connections`' own `listMcpServerConnections`
Expand Down Expand Up @@ -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);
});

Expand Down
Loading