mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +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>
111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
import { badRequest } from "../errors.js";
|
|
|
|
type Connection = { config: Record<string, unknown> };
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function leafName(name: string): string {
|
|
return (name.split(/[.:/]/).pop() ?? name)
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
.replace(/_/g, "-")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function isOfficialGmailConnection(connection: Connection): boolean {
|
|
const config = connection.config;
|
|
const profile = record(config.oauth).connectorProfile;
|
|
if (
|
|
config.sourceTemplateKey === "gmail"
|
|
|| profile === "gmail.read"
|
|
|| profile === "gmail.draft"
|
|
|| profile === "gmail.archive"
|
|
) return true;
|
|
try {
|
|
return new URL(String(config.url)).hostname === "gmailmcp.googleapis.com";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isArchiveConnection(connection: Connection): boolean {
|
|
const config = connection.config;
|
|
return record(config.oauth).connectorProfile === "gmail.archive"
|
|
|| (config.sourceTemplateKey === "gmail"
|
|
&& (config.connectionMethodKey === "paperclip-archive"
|
|
|| config.connectionMethodKey === "customer-archive-oauth"));
|
|
}
|
|
|
|
function isUnlabelThread(connection: Connection, toolName: string): boolean {
|
|
return isOfficialGmailConnection(connection) && leafName(toolName) === "unlabel-thread";
|
|
}
|
|
|
|
export function assertGmailArchiveToolArgumentsSupported(
|
|
connection: Connection,
|
|
toolName: string,
|
|
parameters: unknown,
|
|
): void {
|
|
if (!isUnlabelThread(connection, toolName)) return;
|
|
if (!isArchiveConnection(connection)) {
|
|
throw badRequest("Gmail archiving requires an explicit Read & archive connection.", {
|
|
code: "gmail_archive_profile_required",
|
|
});
|
|
}
|
|
const supplied = record(parameters);
|
|
const keys = Object.keys(supplied).sort();
|
|
if (
|
|
keys.length !== 2
|
|
|| keys[0] !== "labelIds"
|
|
|| keys[1] !== "threadId"
|
|
|| typeof supplied.threadId !== "string"
|
|
|| supplied.threadId.trim().length === 0
|
|
|| !Array.isArray(supplied.labelIds)
|
|
|| supplied.labelIds.length !== 1
|
|
|| supplied.labelIds[0] !== "INBOX"
|
|
) {
|
|
throw badRequest("Gmail archive accepts only threadId and labelIds: [\"INBOX\"].", {
|
|
code: "gmail_archive_arguments_unsupported",
|
|
});
|
|
}
|
|
}
|
|
|
|
export function gmailArchiveToolInputSchema(
|
|
connection: Connection,
|
|
toolName: string,
|
|
schema: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
if (!isUnlabelThread(connection, toolName) || !isArchiveConnection(connection)) return schema;
|
|
return {
|
|
type: "object",
|
|
properties: {
|
|
threadId: {
|
|
type: "string",
|
|
minLength: 1,
|
|
description: "The Gmail thread ID to archive.",
|
|
},
|
|
labelIds: {
|
|
type: "array",
|
|
items: { type: "string", enum: ["INBOX"] },
|
|
minItems: 1,
|
|
maxItems: 1,
|
|
description: "Must be exactly [\"INBOX\"].",
|
|
},
|
|
},
|
|
required: ["threadId", "labelIds"],
|
|
additionalProperties: false,
|
|
};
|
|
}
|
|
|
|
export function gmailArchiveToolDescription(
|
|
connection: Connection,
|
|
toolName: string,
|
|
description: string | null,
|
|
): string | null {
|
|
return isUnlabelThread(connection, toolName) && isArchiveConnection(connection)
|
|
? "Archive a Gmail thread by removing only its Inbox label. Requires human approval."
|
|
: description;
|
|
}
|