fix(connections): return exact catalog names after invalid requests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 11:23:42 -05:00
1 parent ba9b0b4610
commit 09a776bcb1
3 files changed
+58 -1

No files matched your search

+7
View File
@@ -75,3 +75,10 @@ Circleback/Attio/ClickUp, indexed Executor tools, and provider consent. The nati
tool authority test searches a paragraph longer than the old 200-character limit
and then creates a real connection interaction. Existing tests retain checks for
private metadata, administrative denials, stale identities, and saved declines.
When `connection_request` rejects an unknown `toolNames` entry, its error includes
up to twenty exact names from that eligible connection's active tool catalog.
This is discovery metadata: the request still fails with 422, grants no access,
and creates no approval card. The agent must select the needed exact names and
submit a new request; normal user approval, catalog-version and policy checks
still apply. Ineligible connections and inactive catalog entries are excluded.
@@ -608,6 +608,51 @@ const support = await getEmbeddedPostgresTestSupport();
);
expect(result.instruction).toContain("Arcade");
});
it("returns exact eligible catalog names after a guessed tool without granting access", async () => {
await resetQuestions();
const connection = await seedProvider("arcade", "Hubspot_ListContacts", "responsible-user", false);
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
await seedProvider("composio", "Unrelated_PrivateTool", "other-user", false);
await db.insert(toolCatalogEntries).values({ companyId: claims.company_id, connectionId: connection.id,
toolName: "Hubspot_RemovedTool", name: "Removed", versionHash: "old", entryKind: "tool", status: "removed" });
const answer = await selectProvider("via:arcade:hubspot");
const service = connectionIntentService(db);
const error = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id, toolNames: ["hubspot_list_contacts"],
}).catch(error => error);
expect(error).toMatchObject({ status: 422 });
expect(error.message).toContain('"Hubspot_ListContacts"');
expect(error.message).toContain("Request only the needed exact names");
expect(error.message).not.toMatch(/Unrelated_PrivateTool|Hubspot_RemovedTool/);
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, claims.company_id));
expect(interactions.map(row => [row.id, row.status])).toEqual([[answer.id, "answered"]]);
const requested = await service.request(claims, "via:arcade:hubspot", {
selectionInteractionId: answer.id, toolNames: ["Hubspot_ListContacts"],
});
expect(requested.state).toBe("needs_user_action");
const loaded = await service.loadIntent(requested.interactionId!);
expect(loaded.interaction.payload.accessRequest?.tools.map(tool => tool.toolName)).toEqual(["Hubspot_ListContacts"]);
expect(loaded.interaction.status).toBe("pending");
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, claims.company_id))).toEqual([]);
});
it("bounds catalog-name recovery and does not disclose an ineligible connection", async () => {
await resetQuestions();
const connection = await seedProvider("arcade", "Read_00", "responsible-user", false);
await db.insert(toolCatalogEntries).values(Array.from({ length: 24 }, (_, index) => ({
companyId: claims.company_id, connectionId: connection.id, toolName: `Read_${String(index + 1).padStart(2, "0")}`,
name: `Read_${String(index + 1).padStart(2, "0")}`, versionHash: "v1", entryKind: "tool" as const, status: "active" as const,
})));
const service = connectionIntentService(db);
const error = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
expect(error).toMatchObject({ status: 422 });
expect(error.message).toContain("first 20 of 25");
expect(error.message.match(/Read_\d{2}/g)).toHaveLength(20);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, connection.id));
const denied = await service.request(claims, "arcade", { connectionId: connection.id, toolNames: ["guessed"] }).catch(error => error);
expect(denied.message).not.toContain("Read_");
expect(denied.message).toContain("not eligible");
});
it("does not switch providers when the chosen route loses permission", async () => {
await resetQuestions();
const answer = await selectProvider("via:arcade:hubspot");
+6 -1
View File
@@ -739,7 +739,12 @@ export function connectionIntentService(db: Db) {
const catalog = (await indexedCatalog(connection.id, context.run.companyId)).filter(tool => tool.entryKind === "tool");
const names = options.toolNames ?? (app.slug === "composio" ? ["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_MANAGE_CONNECTIONS"] : catalog.map(tool => tool.toolName));
const tools = names.map(name => catalog.find(tool => tool.toolName === name));
if (options.toolNames && tools.some(tool => !tool)) throw unprocessable("A requested tool is not in this connection's active catalog");
if (options.toolNames && tools.some(tool => !tool)) {
// Return discovery metadata only after validating this connection's
// eligibility. Never substitute guessed names or grant access here.
const availableNames = catalog.map(tool => tool.toolName).sort();
throw unprocessable(`A requested tool is not in this connection's active catalog. Available indexed tool names (first ${Math.min(20, availableNames.length)} of ${availableNames.length}): ${JSON.stringify(availableNames.slice(0, 20))}. Request only the needed exact names with connection_request. No access was granted.`);
}
const effective = await access.getEffectiveProfilesForAgent(context.run.companyId, context.agent.id);
const installed = effective.installedConnections.some(item => item.id === connection.id);
const missing = !installed || tools.some(tool => tool && !effective.allowedTools.some(allowed => allowed.id === tool.id));