mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(guidance): tell agents who acts and which page to link for person-only steps
Agents asked to connect an assistant, use a quarantined app action, chat over a disabled channel, connect an unlisted service with an API key, or finish another person's connection request knew a person had to act but found no page to link. They searched until their turn ran out and never replied. - API catalog: assistant connection setup and consent, quarantined catalog entries, and addressed connection requests now say who acts, link the exact page, and say not to search further. - connections_search: a chat channel hidden by the Chat connectors experiment is returned as unavailable with the Experimental settings link; an unlisted service with its own server URL is pointed at the secure Connect your own MCP server page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb759fbe6f
commit
1e183dbea8
4 files changed
+138
-5
No files matched your search
@@ -226,6 +226,34 @@ describeEmbeddedPostgres("connectionIntentService", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names the instance setting and its page when a requested chat channel is turned off", async () => {
|
||||
const settings = instanceSettingsService(db);
|
||||
const previous = await settings.getExperimental();
|
||||
const service = connectionIntentService(db);
|
||||
try {
|
||||
await settings.updateExperimental({ enableChatConnectors: false });
|
||||
const off = await service.search(claims, "iMessage");
|
||||
const imessage = off.results.find(item => item.service === "imessage-photon");
|
||||
expect(imessage).toMatchObject({ state: "unavailable", methods: [] });
|
||||
expect(imessage?.reason).toMatch(/\[Experimental settings\]\(\/[A-Z0-9]+\/company\/settings\/instance\/experimental\)/);
|
||||
expect(off.instruction).toContain("Explain the reason");
|
||||
// Channels that also offer tools keep their usual result; only their chat methods stay hidden.
|
||||
expect((await service.search(claims, "slack")).results.some(item => item.state === "unavailable" && item.methods.length === 0)).toBe(false);
|
||||
await settings.updateExperimental({ enableChatConnectors: true });
|
||||
const on = (await service.search(claims, "iMessage")).results.find(item => item.service === "imessage-photon");
|
||||
expect(on?.state).not.toBe("unavailable");
|
||||
expect(on?.methods.some(method => method.purpose === "channel")).toBe(true);
|
||||
} finally {
|
||||
await settings.updateExperimental({ enableChatConnectors: previous.enableChatConnectors });
|
||||
}
|
||||
});
|
||||
|
||||
it("points a service the catalog does not list at the secure own-server setup page", async () => {
|
||||
const result = await connectionIntentService(db).search(claims, "Synthetic notes service");
|
||||
expect(result.instruction).toMatch(/\[Connect your own MCP server\]\(\/[A-Z0-9]+\/apps\/byo\)/);
|
||||
expect(result.instruction).toContain("never ask for a key in chat");
|
||||
});
|
||||
|
||||
it("creates one addressed request, resolves after install, and then reports ready for the responsible user", async () => {
|
||||
const service = connectionIntentService(db);
|
||||
const first = await service.request(claims, "notion");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
// Agents asked for a step only a person can take used to search the API until their turn ran out,
|
||||
// because no catalog entry they read said who acts or where. Each entry below names both.
|
||||
it.each([
|
||||
["connect my assistant mcp setup", "GET /api/mcp/setup"],
|
||||
["assistant connection consent approve access", "POST /api/mcp/requests/{id}/consent"],
|
||||
])("search %j says only the person connects an assistant, and links the page", (query, operationId) => {
|
||||
const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId);
|
||||
expect(operation, query).toBeDefined();
|
||||
expect(operation!.description).toContain("[Assistant connections](/<prefix>/apps/assistant-connection)");
|
||||
expect(operation!.description).toContain("a read-only member can grant read access but not writes");
|
||||
expect(operation!.description).toContain("Do not search for another route");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tool connection catalog quarantined", "GET /api/tool-connections/{connectionId}/catalog"],
|
||||
["quarantined catalog entry counts review", "GET /api/tool-connections/{connectionId}/review-counts"],
|
||||
["tool apps needing attention", "GET /api/companies/{companyId}/tools/apps/attention"],
|
||||
])("search %j says where a person reviews a quarantined action", (query, operationId) => {
|
||||
const operation = searchRunnerApi({ query, limit: 10 }).results.find(row => row.operationId === operationId);
|
||||
expect(operation, query).toBeDefined();
|
||||
expect(operation!.description).toContain("[Permissions](/<prefix>/apps/<connectionId>/permissions)");
|
||||
expect(operation!.description).toContain("No agent can call a quarantined action");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"complete connection request for someone else",
|
||||
"connection intent resolve decline",
|
||||
])("search %j says only the addressee can resolve a connection request card", (query) => {
|
||||
const found = searchRunnerApi({ query, limit: 10 }).results.filter(row => row.path.startsWith("/api/connection-intents/"));
|
||||
expect(found.length, query).toBeGreaterThan(0);
|
||||
for (const operation of found) {
|
||||
expect(operation.description).toContain("Only that person, signed in to their own browser");
|
||||
expect(operation.description).toContain("[<identifier>](/<prefix>/issues/<identifier>)");
|
||||
}
|
||||
});
|
||||
|
||||
// The catalog text is the only place these page paths live on the server, so a renamed UI route
|
||||
// would leave agents linking a page that no longer exists. Every page named must be mounted.
|
||||
it("names only pages that the board UI mounts", () => {
|
||||
const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8");
|
||||
const pages = new Set(
|
||||
runnerApiCatalog()
|
||||
.filter(row => /^\/api\/(mcp\/(setup|requests\/\{id\}\/consent)$|connection-intents\/|tool-connections\/\{connectionId\}\/(catalog|review-counts)$|companies\/\{companyId\}\/tools\/apps\/attention$)/.test(row.path))
|
||||
.flatMap(row => [...(row.description ?? "").matchAll(/\/<prefix>\/([a-z0-9/<>-]+)/gi)].map(match => match[1]!)),
|
||||
);
|
||||
expect([...pages].sort()).toEqual(["apps/<connectionId>/permissions", "apps/assistant-connection", "issues/<identifier>"]);
|
||||
const mounted = (page: string) => page
|
||||
.replace("apps/<connectionId>/permissions", "apps/:connectionId/:tab")
|
||||
.replace("issues/<identifier>", "issues/:issueId");
|
||||
for (const page of pages) expect(appRoutes, page).toContain(`<Route path="${mounted(page)}" element=`);
|
||||
expect(readFileSync(path.join(repoRoot, "ui/src/pages/apps/app-tabs.ts"), "utf8")).toContain("\"permissions\"");
|
||||
});
|
||||
@@ -11350,11 +11350,18 @@ registerCurrentRoute({
|
||||
body: connectionRequestInputSchema,
|
||||
});
|
||||
|
||||
/** Agents read this in API search; without it they look for a way to resolve another person's card. */
|
||||
const ADDRESSED_CONNECTION_REQUEST = "Person-only: a connection request card is addressed to one person, its addresseeUserId. "
|
||||
+ "Only that person, signed in to their own browser, can open its setup, connect their account or decline it. The server refuses every other member, board user and agent, so nobody can finish it for them. "
|
||||
+ "When someone else offers to finish it, tell them the addressee must connect their own account from the card on its task, linked as [<identifier>](/<prefix>/issues/<identifier>). "
|
||||
+ "Do not create another request for the addressee and do not search for another route.";
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/connection-intents/{interactionId}/setup-options",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Get setup options for an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -11362,6 +11369,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/phase",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Update the setup phase for an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: z
|
||||
.object({
|
||||
phase: z.enum(["requested", "authorizing", "needs_retry"]),
|
||||
@@ -11383,6 +11391,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/complete",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Complete an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: completeConnectionIntentSchema,
|
||||
});
|
||||
|
||||
@@ -11408,6 +11417,7 @@ registerCurrentRoute({
|
||||
path: "/api/connection-intents/{interactionId}/decline",
|
||||
tags: ["connection-intents"],
|
||||
summary: "Decline an addressed connection request",
|
||||
description: ADDRESSED_CONNECTION_REQUEST,
|
||||
body: declineConnectionIntentSchema,
|
||||
});
|
||||
|
||||
@@ -11709,6 +11719,12 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
/** Agents read this in API search; without it they cannot find where a quarantined action is reviewed. */
|
||||
const QUARANTINED_ACTION_REVIEW = "Catalog entries with status quarantined are new actions that a catalog refresh found and holds for a person's review. "
|
||||
+ "No agent can call a quarantined action, through this connection or any other transport, until a person who manages the connection allows it on the app's Permissions page, "
|
||||
+ "[Permissions](/<prefix>/apps/<connectionId>/permissions), with this conversation's company prefix and the connection's id. Allowing it is that person's decision, not an agent's. "
|
||||
+ "When asked to use a quarantined action, say it is waiting for review, link that page and reply; do not search for another route.";
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:read",
|
||||
@@ -11716,6 +11732,7 @@ registerCurrentRoute({
|
||||
path: "/api/companies/{companyId}/tools/apps/attention",
|
||||
tags: ["tool-access"],
|
||||
summary: "List tool apps needing attention",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -11932,6 +11949,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-connections/{connectionId}/review-counts",
|
||||
tags: ["tool-access"],
|
||||
summary: "Get pending action request and quarantined catalog entry counts for one connection",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -12051,6 +12069,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-connections/{connectionId}/catalog",
|
||||
tags: ["tool-access"],
|
||||
summary: "List a tool connection catalog",
|
||||
description: QUARANTINED_ACTION_REVIEW,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
@@ -12923,9 +12942,17 @@ registerCurrentRoute({
|
||||
400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
/** Agents read this in API search; without it they hunt for the page where a person connects an assistant. */
|
||||
const ASSISTANT_CONNECTION_SETUP_PERSON_ONLY = "Person-only: a person connects their own assistant, such as Claude, ChatGPT or another MCP client, in their browser on "
|
||||
+ "[Assistant connections](/<prefix>/apps/assistant-connection), with this conversation's company prefix as <prefix>. "
|
||||
+ "That page gives the invitation and server URL to paste into the assistant; the assistant then opens a Paperclip consent screen where the person picks the company and approves access. "
|
||||
+ "The consent screen offers only access within that person's own role: a read-only member can grant read access but not writes. "
|
||||
+ "An agent cannot read this setup or approve consent for anyone: these routes accept only the person's own signed-in browser session. "
|
||||
+ "Do not search for another route; link that page, say the person approves access there themselves, and reply.";
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/mcp/setup", tags: ["tool-gateway"],
|
||||
summary: "Read assistant connection setup using a human browser session",
|
||||
description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY,
|
||||
// Available while disabled; returns metadata only and never grants access.
|
||||
responses: {
|
||||
200: r.ok(z.object({ enabled: z.boolean(), serverUrl: z.string().url(), invitationUrl: z.string().url(), invitation: z.string() })),
|
||||
@@ -12966,6 +12993,7 @@ for (const suffix of ["/dot-pairing/preview", "/dot-pairing"]) registerCurrentRo
|
||||
registerCurrentRoute({
|
||||
method: "post", path: "/api/mcp/requests/{id}/consent", tags: ["tool-gateway"],
|
||||
summary: "Approve or deny assistant access using a same-origin browser session",
|
||||
description: ASSISTANT_CONNECTION_SETUP_PERSON_ONLY,
|
||||
body: mcpConsentSchema,
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
@@ -543,6 +543,21 @@ export function connectionIntentService(db: Db) {
|
||||
connectionId: ready?.id ?? null,
|
||||
}});
|
||||
}
|
||||
// A chat channel hidden by the instance setting stays findable by name, so the agent can say why
|
||||
// it is unavailable and where it is turned on instead of searching for a route that is not offered.
|
||||
if (!settings.enableChatConnectors) for (const app of APP_STORE_DEFINITIONS) {
|
||||
if (!isConnectorFeatureEnabled(app, settings) || discoveryMethods(app).length
|
||||
|| !getAvailableConnectionMethods(app).some(method => method.purpose === "channel" && method.provider !== "github")) continue;
|
||||
const { score, nameScore } = scoreConnectionSearch(preparedQuery, [app.slug, app.name], "");
|
||||
if (!nameScore) continue;
|
||||
candidates.push({ score, nameScore, item: {
|
||||
service: app.slug, name: app.name, description: app.description ?? null, logoUrl: app.branding.logoUrl ?? null,
|
||||
methods: [], source: "catalog", state: "unavailable", connectionId: null,
|
||||
reason: `${app.name} chat is turned off on this instance. An instance admin turns it on with Chat connectors on [Experimental settings](/${company.prefix}/company/settings/instance/experimental); `
|
||||
+ "then a person who manages connections can set it up. Agents cannot change instance settings. "
|
||||
+ "Tell the person this, link that page and reply; do not create a chat endpoint, substitute another channel or search for another route.",
|
||||
}});
|
||||
}
|
||||
// A yielded native service is not offered for new setup when an aggregator
|
||||
// route can serve it; its routes take its place in ranked results, so broad
|
||||
// and capability-only searches still find the app. Without a route the
|
||||
@@ -588,7 +603,7 @@ export function connectionIntentService(db: Db) {
|
||||
// Aggregator routes need a recorded provider choice before connection_request.
|
||||
const rankedResult = (suggestions = false) => {
|
||||
const results = ranked();
|
||||
return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, suggestions);
|
||||
return results.some(item => item.source === "aggregator") ? discoverySuggestions(results) : directSearchResult(query, results, company.prefix, suggestions);
|
||||
};
|
||||
const targetService = publicService?.slug ?? (indexedMatches.length === 1 ? indexedMatches[0]!.slug : normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-"));
|
||||
// Indexed-only app labels must be identical when requests re-search the slug.
|
||||
@@ -696,13 +711,15 @@ export function connectionIntentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult {
|
||||
function directSearchResult(query: string, results: ConnectionSearchResultItem[], prefix: string, suggestions = false): ConnectionsSearchResult {
|
||||
// A service the catalog does not list can still be the person's own MCP server; name its secure setup page.
|
||||
const ownServer = `they can add it as their own MCP server on [Connect your own MCP server](/${prefix}/apps/byo), which takes the URL and keeps any API key in a secure field. Link that page; never ask for a key in chat.`;
|
||||
return { version: 1, query, results, instruction: !results.length
|
||||
? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services."
|
||||
? `No verified connection route was found. If the person gave the service's own server URL, ${ownServer} Otherwise explain that support could not be verified; do not invent a provider route or request unsupported services.`
|
||||
: !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable"
|
||||
? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}`
|
||||
: !suggestions && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||||
: suggestions || results.length > 1 ? "These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support."
|
||||
: (!suggestions || results.length === 1) && results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider."
|
||||
: suggestions || results.length > 1 ? `These are ranked connection matches. Choose the relevant service and method using their descriptions and purposes; extra query words need not match. For user-requested configuration, inspect method.management and use search_api/call_api with nextApiOperation when allowed. Hand off only required authentication, consent, credentials, or decisions at the saved setupPath destination. Use connection_request when human setup or access is required. Use ready tools as installed; ready AI authentication is available for the agent's next execution and does not need reconnection. Respect unavailable states and recorded user choices. Clarify only if the intended service is still ambiguous; unrelated matches are not evidence of support. If none of these is the service the person named and they gave that service's own server URL, ${ownServer}`
|
||||
: results[0]!.state === "ready" && results[0]!.methods.every(method => method.purpose === "ai")
|
||||
? "AI authentication is available for this agent's next execution. Do not create another connection request or ask the user to reconnect."
|
||||
: results[0]!.service === "agentmail"
|
||||
|
||||
Reference in new issue
Block a user