mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(connections): return exact catalog names after invalid requests
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ba9b0b4610
commit
09a776bcb1
3 files changed
+58
-1
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user