mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path > - Paperclip separates Gmail capabilities by reviewed OAuth scope and tool policy. > - Existing Gmail connections support reading and draft creation but not bounded archiving. > - Gmail archive requires `gmail.modify`, so it must not widen existing read or draft grants. > - This change adds an opt-in archive profile and limits it to removing only the `INBOX` label. > - A final pre-dispatch check preserves that boundary for approved and replayed calls. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the existing Gmail connection and tool-governance behavior. **Subsystem affected** Cross-cutting: `packages/shared` and `server`. **Current behavior** Paperclip can read Gmail and create drafts. It does not provide an opt-in, bounded action for archiving a thread. **Proposed behavior** Paperclip offers a separate Read & archive connection. It exposes `unlabel_thread` only for that connection and accepts only `threadId` with `labelIds: ["INBOX"]`. **Reason and benefit** Users can archive processed Gmail threads while sending, trashing, and other label changes remain disabled. **Breaking changes** None. Existing read and draft methods keep their current scopes and defaults. Related work: #15545 adds a unified Google Workspace connector and keeps Gmail draft-only. #15412 adds a separate Gmail REST server without label mutations. Neither PR adds this bounded archive capability. ## What Changed - Added the `gmail.archive` connector profile. - Added managed and customer-owned archive connection methods. - Added an Inbox-only schema and final pre-dispatch argument guard. - Classified archive as destructive and added Ask-first defaults for the archive methods. - Added permission-review records and focused tests. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server build` - `pnpm --filter @paperclipai/shared typecheck` - Focused Vitest suites: 42 tests passed. - App-definition ingestion: 103 definitions and 0 review flags. - `git diff --check` ## Risks The main risk is accidental scope or operation expansion. The new profile is opt-in. Existing profiles retain their scopes. The gateway validates the exact Inbox-only payload immediately before dispatch. Production rollout requires separate OAuth configuration and fresh consent. ## Model Used Unverified — reliable runtime model metadata was not available. Confirm the model attribution before publishing. ## Checklist - [x] I have included a concise design rationale - [x] I have specified that model attribution is unverified - [x] I have checked ROADMAP.md and found no Gmail archive entry - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in this PR - [x] I have not referenced internal or instance-local issues - [x] My branch name describes the change - [x] Local tests and builds pass - [x] I have added or updated tests - [x] I have updated relevant documentation and permission records - [x] I have documented risks - [ ] All hosted CI gates are green - [ ] Greptile and human review are complete --------- Co-authored-by: Leia Marshall <leia@paperclip.ing>
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
assertGmailArchiveToolArgumentsSupported,
|
|
gmailArchiveToolDescription,
|
|
gmailArchiveToolInputSchema,
|
|
} from "./gmail-archive-tool-policy.js";
|
|
|
|
const archive = { config: {
|
|
sourceTemplateKey: "gmail",
|
|
connectionMethodKey: "paperclip-archive",
|
|
oauth: { connectorProfile: "gmail.archive" },
|
|
} };
|
|
|
|
describe("Gmail archive tool policy", () => {
|
|
it("accepts only removal of the Inbox label from one thread", () => {
|
|
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", {
|
|
threadId: "thread-123",
|
|
labelIds: ["INBOX"],
|
|
})).not.toThrow();
|
|
|
|
for (const parameters of [
|
|
{ threadId: "thread-123", labelIds: ["STARRED"] },
|
|
{ threadId: "thread-123", labelIds: ["INBOX", "UNREAD"] },
|
|
{ threadId: "thread-123", label_ids: ["INBOX"] },
|
|
{ threadId: "", labelIds: ["INBOX"] },
|
|
{ threadId: "thread-123", labelIds: ["INBOX"], extra: true },
|
|
"encoded",
|
|
]) {
|
|
expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", parameters))
|
|
.toThrow(expect.objectContaining({
|
|
status: 400,
|
|
details: { code: "gmail_archive_arguments_unsupported" },
|
|
}));
|
|
}
|
|
});
|
|
|
|
it("requires an explicit archive connection even for the official Gmail endpoint", () => {
|
|
for (const config of [
|
|
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-read", oauth: { connectorProfile: "gmail.read" } },
|
|
{ sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-draft", oauth: { connectorProfile: "gmail.draft" } },
|
|
{ url: "https://gmailmcp.googleapis.com/mcp/v1" },
|
|
]) {
|
|
expect(() => assertGmailArchiveToolArgumentsSupported({ config }, "unlabel_thread", {
|
|
threadId: "thread-123", labelIds: ["INBOX"],
|
|
})).toThrow(expect.objectContaining({
|
|
status: 400,
|
|
details: { code: "gmail_archive_profile_required" },
|
|
}));
|
|
}
|
|
});
|
|
|
|
it("publishes an Inbox-only schema without mutating the provider schema", () => {
|
|
const providerSchema = { type: "object", properties: {
|
|
threadId: { type: "string" },
|
|
labelIds: { type: "array", items: { type: "string" } },
|
|
} };
|
|
const projected = gmailArchiveToolInputSchema(archive, "gmail/unlabel_thread", providerSchema);
|
|
expect(projected).toMatchObject({
|
|
properties: { labelIds: { items: { enum: ["INBOX"] }, minItems: 1, maxItems: 1 } },
|
|
required: ["threadId", "labelIds"],
|
|
additionalProperties: false,
|
|
});
|
|
expect(providerSchema.properties.labelIds.items).toEqual({ type: "string" });
|
|
});
|
|
|
|
it("describes archive narrowly and leaves unrelated tools unchanged", () => {
|
|
expect(gmailArchiveToolDescription(archive, "unlabel_thread", "Remove labels."))
|
|
.toContain("removing only its Inbox label");
|
|
expect(gmailArchiveToolDescription(archive, "search_threads", "Search mail."))
|
|
.toBe("Search mail.");
|
|
expect(() => assertGmailArchiveToolArgumentsSupported(
|
|
{ config: { sourceTemplateKey: "other" } },
|
|
"unlabel_thread",
|
|
{ anything: true },
|
|
)).not.toThrow();
|
|
});
|
|
});
|