mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(api): keep the protocol refusal and revoke guidance accurate
- Point a call_api protocol refusal at the catalog description only when that operation has one; undocumented protocol routes no longer send the agent to an empty description. - Say the connected assistant can still revoke its own token through OAuth token revocation instead of claiming no route exists. - Cover the signed-in-person gate on listing and revoking assistant connections for agents and non-session board actors. - Drop the code comments the contribution guide disallows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c27248643
commit
843e16db8b
4 files changed
+16
-5
No files matched your search
@@ -5,14 +5,14 @@ import { validateRunnerApiCall } from "../services/native-runtime/runner-api-cli
|
||||
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).toContain("Do not search for another route");
|
||||
expect(revoke!.description).toContain("OAuth token revocation sent by the connected assistant itself");
|
||||
expect(revoke!.description).toMatch(/already created keep their assignees and running work/);
|
||||
}
|
||||
const list = searchRunnerApi({ query: "GET /api/mcp/connections" }).results[0]!;
|
||||
@@ -23,3 +23,12 @@ it("says the call_api protocol rejection is final instead of inviting another se
|
||||
expect(() => validateRunnerApiCall({ operationId: "GET /api/mcp/connections" }, context))
|
||||
.toThrow(/no other API route substitutes for it\. Its catalog description says who can use it\./);
|
||||
});
|
||||
|
||||
it("points at the catalog description only when the protocol endpoint has one", () => {
|
||||
const undocumented = searchRunnerApi({ query: "POST /api/mcp/project-tools" }).results.find(row => row.operationId === "POST /api/mcp/project-tools");
|
||||
expect(undocumented?.description).toBe("");
|
||||
let message = "";
|
||||
try { validateRunnerApiCall({ operationId: "POST /api/mcp/project-tools" }, context); } catch (error) { message = (error as Error).message; }
|
||||
expect(message).toContain("no other API route substitutes for it.");
|
||||
expect(message).not.toContain("catalog description");
|
||||
});
|
||||
@@ -466,6 +466,8 @@ describe.skipIf(!support.supported)("public MCP OAuth and tool boundary", () =>
|
||||
] as Request["actor"][]) {
|
||||
actor = candidate;
|
||||
expect((await request(app).get("/api/mcp/setup")).status).toBe(401);
|
||||
expect((await request(app).get("/api/mcp/connections")).status).toBe(401);
|
||||
expect((await request(app).delete(`/api/mcp/connections/${randomUUID()}`).set("Origin", config.origin)).status).toBe(401);
|
||||
}
|
||||
for (const source of ["session", "cloud_tenant"] as const) {
|
||||
actor = { type: "board", source, userId: randomUUID() };
|
||||
|
||||
@@ -12957,10 +12957,10 @@ 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. "
|
||||
+ "An agent cannot do it for them: these routes accept only the person's own signed-in browser session, and the only other revocation is OAuth token revocation sent by the connected assistant itself with its own token. "
|
||||
+ "Do not search for another route; 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"],
|
||||
|
||||
@@ -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 and no other API route substitutes for it. Its catalog description says who can use it.");
|
||||
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.${operation.description.trim() ? " 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