mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(api): tell agents only the person can revoke assistant connections, and where
A chat agent asked to "disconnect my old assistant" found DELETE
/api/mcp/connections/{id}, was refused by call_api as a protocol
endpoint, and then spent its whole turn searching for an agent route
that does not exist, never replying. Neither the catalog entry nor the
rejection said the action is person-only or which page holds it.
Both assistant connection catalog entries now say only the signed-in
person can list or revoke them, link the Assistant connections page,
say not to search for another route, and state that revoking leaves
already-delegated tasks running. The call_api protocol rejection now
says no other API route substitutes for the endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f81933d010
commit
8ce3389fb5
3 files changed
+33
-1
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js";
|
||||
import { validateRunnerApiCall } from "../services/native-runtime/runner-api-client.js";
|
||||
|
||||
const context = { companyId: "00000000-0000-4000-8000-000000000121", issueId: "00000000-0000-4000-8000-000000000122", runId: "00000000-0000-4000-8000-000000000123", workMode: "standard" };
|
||||
|
||||
it("tells agents searching for assistant disconnection that only the person can do it, and where", () => {
|
||||
// The queries a chat agent used while looking for a way to disconnect a person's old assistant.
|
||||
for (const query of ["chat client assistant disconnect connection", "assistant connections revoke list", "mcp connection revoke REST"]) {
|
||||
const found = searchRunnerApi({ query, limit: 10 }).results;
|
||||
const revoke = found.find(row => row.operationId === "DELETE /api/mcp/connections/{id}");
|
||||
expect(revoke, query).toBeDefined();
|
||||
expect(revoke!.description).toContain("[Assistant connections](/<prefix>/apps/assistant-connection)");
|
||||
expect(revoke!.description).toContain("only the signed-in person");
|
||||
expect(revoke!.description).toContain("do not search for one");
|
||||
expect(revoke!.description).toMatch(/already created keep their assignees and running work/);
|
||||
}
|
||||
const list = searchRunnerApi({ query: "GET /api/mcp/connections" }).results[0]!;
|
||||
expect(list.description).toContain("[Assistant connections](/<prefix>/apps/assistant-connection)");
|
||||
});
|
||||
|
||||
it("says the call_api protocol rejection is final instead of inviting another search", () => {
|
||||
expect(() => validateRunnerApiCall({ operationId: "GET /api/mcp/connections" }, context))
|
||||
.toThrow(/no other API route substitutes for it\. Its catalog description says who can use it\./);
|
||||
});
|
||||
@@ -12967,14 +12967,21 @@ registerCurrentRoute({
|
||||
body: mcpConsentSchema.extend({ userCode: z.string().min(8).max(12) }),
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
/** Agents read this in API search; without it they keep searching for an agent route that does not exist. */
|
||||
const ASSISTANT_CONNECTIONS_PERSON_ONLY = "Personal and person-only: only the signed-in person can list or revoke their own assistant connections, in their browser on "
|
||||
+ "[Assistant connections](/<prefix>/apps/assistant-connection), with this conversation's company prefix as <prefix>. "
|
||||
+ "No agent credential, tool or other API route can do it, so do not search for one; link that page and tell the person which connection to revoke. "
|
||||
+ "Revoking stops that assistant's further calls; tasks it already created keep their assignees and running work.";
|
||||
registerCurrentRoute({
|
||||
method: "get", path: "/api/mcp/connections", tags: ["tool-gateway"],
|
||||
summary: "List the signed-in person's assistant connections",
|
||||
description: ASSISTANT_CONNECTIONS_PERSON_ONLY,
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
registerCurrentRoute({
|
||||
method: "delete", path: "/api/mcp/connections/{id}", tags: ["tool-gateway"],
|
||||
summary: "Revoke an assistant connection using a same-origin browser session",
|
||||
description: ASSISTANT_CONNECTIONS_PERSON_ONLY,
|
||||
responses: { 204: { description: "Connection revoked" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
|
||||
if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues });
|
||||
const input = parsed.data;
|
||||
const operation = runnerApiOperation(input.operationId);
|
||||
if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only");
|
||||
if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it. Its catalog description says who can use it.");
|
||||
if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation");
|
||||
if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
|
||||
if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");
|
||||
|
||||
Reference in new issue
Block a user