gogcli-mcp 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +647 -67
- package/dist/lib.js +654 -67
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/gmail-dispatch-guard.ts +36 -36
- package/src/lib.ts +9 -0
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +24 -3
- package/src/tools/calendar.ts +168 -7
- package/src/tools/chat.ts +78 -5
- package/src/tools/classroom.ts +83 -3
- package/src/tools/drive.ts +55 -3
- package/src/tools/gmail.ts +111 -20
- package/tests/gmail-dispatch-guard.test.ts +227 -14
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/calendar.test.ts +4 -1
- package/tests/tools/chat.test.ts +4 -1
- package/tests/tools/classroom.test.ts +4 -1
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/drive.test.ts +4 -1
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/run-vets.test.ts +131 -0
package/src/tools/gmail.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
7
7
|
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
|
-
import { attachmentNames, bodyPreview, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
|
|
8
|
+
import { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
|
|
9
9
|
import { pos } from '../argv.js';
|
|
10
|
+
import { hasCommandWord } from '../dispatch-confirmation.js';
|
|
10
11
|
import { confinePath, confinePaths } from '../file-roots.js';
|
|
11
12
|
|
|
12
13
|
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
@@ -98,8 +99,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
|
98
99
|
// The send-side reply/reply-all schema, distinct from the shared replySchema
|
|
99
100
|
// above. gog_gmail_drafts_reply / _reply_all (gogcli-mcp-gmail) reuse
|
|
100
101
|
// replySchema verbatim and must never gain a send-confirmation input — draft
|
|
101
|
-
// tools stage mail, while send tools request confirmation through MCP
|
|
102
|
-
|
|
102
|
+
// tools stage mail, while send tools request confirmation (through MCP, or the
|
|
103
|
+
// opt-in confirmToken fallback, which is why only THIS schema carries it).
|
|
104
|
+
const sendReplySchema = z.object({ ...replySchema, confirmToken: confirmTokenParam });
|
|
103
105
|
|
|
104
106
|
// gog's own `reply`/`reply-all` response never echoes the resolved
|
|
105
107
|
// recipients when replying to a single message (the common case: gog only
|
|
@@ -148,6 +150,62 @@ export function computeReplyRecipients(
|
|
|
148
150
|
return emails;
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/** `message.threadId` out of `gog gmail get --format=metadata --json`, if present. */
|
|
154
|
+
export function metadataThreadId(raw: string): string | undefined {
|
|
155
|
+
try {
|
|
156
|
+
const threadId = (JSON.parse(raw) as { message?: { threadId?: unknown } } | null)?.message?.threadId;
|
|
157
|
+
return typeof threadId === 'string' ? threadId : undefined;
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The token fallback's preview and bound payload for a reply. To/Cc/Bcc are the
|
|
164
|
+
// same approximation computeReplyRecipients makes (gog composes the final list),
|
|
165
|
+
// split by field so the user can see who is on Bcc.
|
|
166
|
+
function replyTokenSubject(
|
|
167
|
+
kind: 'reply' | 'reply-all',
|
|
168
|
+
messageId: string,
|
|
169
|
+
account: string | undefined,
|
|
170
|
+
headers: Record<string, string>,
|
|
171
|
+
threadId: string | undefined,
|
|
172
|
+
flags: ReplyFlags,
|
|
173
|
+
) {
|
|
174
|
+
const removed = new Set(extractEmails(...(flags.remove ?? [])));
|
|
175
|
+
const keep = (emails: string[]) => emails.filter((e) => !removed.has(e));
|
|
176
|
+
const to = keep(extractEmails(headers.from, ...(kind === 'reply-all' ? [headers.to] : []), ...(flags.to ?? [])));
|
|
177
|
+
const cc = keep(extractEmails(...(kind === 'reply-all' ? [headers.cc] : []), ...(flags.cc ?? [])));
|
|
178
|
+
const bcc = keep(extractEmails(...(flags.bcc ?? [])));
|
|
179
|
+
const attachments = attachmentDetails(flags.attach, flags.attachInline);
|
|
180
|
+
const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined);
|
|
181
|
+
const from = flags.from ?? (flags.autoFromAddressedAlias ? 'the send-as alias the original was addressed to' : senderPreview(account));
|
|
182
|
+
const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p): p is string => Boolean(p)), undefined);
|
|
183
|
+
return {
|
|
184
|
+
target: messageId,
|
|
185
|
+
payload: {
|
|
186
|
+
kind, to, cc, bcc, subject, from,
|
|
187
|
+
body: flags.body, bodyHtml: flags.bodyHtml, localFiles,
|
|
188
|
+
signature: Boolean(flags.signature), signatureFrom: flags.signatureFrom,
|
|
189
|
+
attachments, quote: !flags.noQuote,
|
|
190
|
+
threadId, inReplyTo: headers.message_id, references: headers.references,
|
|
191
|
+
},
|
|
192
|
+
preview: {
|
|
193
|
+
from, to, cc, bcc, subject,
|
|
194
|
+
body: flags.body,
|
|
195
|
+
...(flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {}),
|
|
196
|
+
...(flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {}),
|
|
197
|
+
...(flags.signature || flags.signatureFrom || flags.signatureFile
|
|
198
|
+
? { signature: flags.signatureFile ?? flags.signatureFrom ?? 'the Gmail signature of the sending address' }
|
|
199
|
+
: {}),
|
|
200
|
+
quotesOriginal: !flags.noQuote,
|
|
201
|
+
attachments: attachmentPreview(attachments),
|
|
202
|
+
replyingToMessageId: messageId,
|
|
203
|
+
threadId,
|
|
204
|
+
inReplyTo: headers.message_id,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
151
209
|
// Shared by gog_gmail_reply and gog_gmail_reply_all: fetch the target
|
|
152
210
|
// message's headers (always — the prompt needs them and so does the audit
|
|
153
211
|
// log on the accepted path), then confirm and send.
|
|
@@ -161,12 +219,14 @@ async function sendReply(
|
|
|
161
219
|
account: string | undefined,
|
|
162
220
|
flags: ReplyFlags,
|
|
163
221
|
ctx: ServerContext,
|
|
222
|
+
confirmToken?: string,
|
|
164
223
|
) {
|
|
165
224
|
const metaResult = await runOrDiagnose(['gmail', 'get', pos(messageId), '--format=metadata'], { account });
|
|
166
225
|
if (metaResult.isError) return metaResult;
|
|
167
|
-
const
|
|
226
|
+
const metaText = resultText(metaResult);
|
|
227
|
+
const headers = parseMetadataHeaders(metaText);
|
|
168
228
|
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
169
|
-
const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
229
|
+
const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
170
230
|
messageId,
|
|
171
231
|
recipients,
|
|
172
232
|
recipientCount: recipients.length,
|
|
@@ -177,6 +237,13 @@ async function sendReply(
|
|
|
177
237
|
bodyHtmlFile: flags.bodyHtmlFile,
|
|
178
238
|
attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
|
|
179
239
|
attachments: attachmentNames(flags.attach, flags.attachInline),
|
|
240
|
+
}, {
|
|
241
|
+
tool: toolName,
|
|
242
|
+
account,
|
|
243
|
+
confirmToken,
|
|
244
|
+
// The metadata read above IS the phase-2 re-read: it runs on every call,
|
|
245
|
+
// so the original's Message-ID/References are fresh here.
|
|
246
|
+
subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags),
|
|
180
247
|
});
|
|
181
248
|
if (confirmation) return confirmation;
|
|
182
249
|
const args: GogArg[] = ['gmail', kind, pos(messageId)];
|
|
@@ -205,12 +272,11 @@ export function vetGmailRun(subcommand: string, args: readonly string[]): string
|
|
|
205
272
|
return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
206
273
|
}
|
|
207
274
|
if (subcommand === 'settings') {
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
const blocked = args.find((a) => GMAIL_RUN_BLOCKED_SETTINGS.has(a.toLowerCase()));
|
|
275
|
+
// A global flag can take its value as the next token (`settings --color
|
|
276
|
+
// never filters ...`), so the word is not necessarily args[0]. Refuse it
|
|
277
|
+
// wherever it appears; a legitimate settings call carrying one of these
|
|
278
|
+
// words as a value is rare and has a dedicated tool anyway.
|
|
279
|
+
const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
|
|
214
280
|
if (blocked) {
|
|
215
281
|
return `gog gmail settings ${blocked} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
216
282
|
}
|
|
@@ -291,7 +357,8 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
291
357
|
+ 'and byte sizes — check it to confirm the files were embedded. '
|
|
292
358
|
+ 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
|
|
293
359
|
+ 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
|
|
294
|
-
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
|
|
360
|
+
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
|
|
361
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
295
362
|
annotations: { destructiveHint: true },
|
|
296
363
|
inputSchema: z.object({
|
|
297
364
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
@@ -305,8 +372,9 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
305
372
|
attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
|
|
306
373
|
attachInline: attachInlineParam,
|
|
307
374
|
account: accountParam,
|
|
375
|
+
confirmToken: confirmTokenParam,
|
|
308
376
|
}),
|
|
309
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
|
|
377
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
|
|
310
378
|
confinePaths(attach, 'attach');
|
|
311
379
|
// Built (and validated — inlineAttachmentArgs throws on bad base64 or an
|
|
312
380
|
// oversize file) BEFORE the confirmation request, on both paths: a prompt that
|
|
@@ -334,7 +402,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
334
402
|
args.push(...inline);
|
|
335
403
|
|
|
336
404
|
const recipients = extractEmails(to, cc, bcc);
|
|
337
|
-
const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
405
|
+
const confirmation = await requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
338
406
|
to, cc, bcc, recipients, recipientCount: recipients.length, subject,
|
|
339
407
|
bodyLength: body.length,
|
|
340
408
|
bodyPreview: bodyPreview(body),
|
|
@@ -342,6 +410,27 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
342
410
|
quoting: Boolean(quote),
|
|
343
411
|
attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
|
|
344
412
|
attachments: attachmentNames(attach, attachInline),
|
|
413
|
+
}, {
|
|
414
|
+
tool: 'gog_gmail_send',
|
|
415
|
+
account,
|
|
416
|
+
confirmToken,
|
|
417
|
+
// Nothing is stored between the phases: the payload IS the arguments,
|
|
418
|
+
// so phase 2 must repeat them and any difference is a changed send.
|
|
419
|
+
subject: () => {
|
|
420
|
+
const attachments = attachmentDetails(attach, attachInline);
|
|
421
|
+
const from = senderPreview(account);
|
|
422
|
+
return {
|
|
423
|
+
target: replyToMessageId ?? threadId ?? '',
|
|
424
|
+
payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
|
|
425
|
+
preview: {
|
|
426
|
+
from, to, cc, bcc, subject, body,
|
|
427
|
+
attachments: attachmentPreview(attachments),
|
|
428
|
+
threadId,
|
|
429
|
+
inReplyTo: replyToMessageId,
|
|
430
|
+
quotesOriginal: Boolean(quote),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
},
|
|
345
434
|
});
|
|
346
435
|
if (confirmation) return confirmation;
|
|
347
436
|
const result = await runOrDiagnose(args, { account });
|
|
@@ -376,13 +465,14 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
376
465
|
+ 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
|
|
377
466
|
+ 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
|
|
378
467
|
+ 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
|
|
379
|
-
+ 'instead of sending it.'
|
|
468
|
+
+ 'instead of sending it.'
|
|
469
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
380
470
|
annotations: { destructiveHint: true },
|
|
381
471
|
inputSchema: sendReplySchema,
|
|
382
|
-
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
472
|
+
}, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
|
|
383
473
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
384
474
|
confineReplyPaths(flags);
|
|
385
|
-
return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx);
|
|
475
|
+
return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx, confirmToken);
|
|
386
476
|
});
|
|
387
477
|
|
|
388
478
|
server.registerTool('gog_gmail_reply_all', {
|
|
@@ -393,13 +483,14 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
393
483
|
+ 'confirmation. '
|
|
394
484
|
+ 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
|
|
395
485
|
+ 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
|
|
396
|
-
+ 'reply-all.'
|
|
486
|
+
+ 'reply-all.'
|
|
487
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
397
488
|
annotations: { destructiveHint: true },
|
|
398
489
|
inputSchema: sendReplySchema,
|
|
399
|
-
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
490
|
+
}, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
|
|
400
491
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
401
492
|
confineReplyPaths(flags);
|
|
402
|
-
return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx);
|
|
493
|
+
return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx, confirmToken);
|
|
403
494
|
});
|
|
404
495
|
|
|
405
496
|
registerRunTool(server, {
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';
|
|
4
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { resetConfirmTokenState } from '../src/send-confirm-token.js';
|
|
4
8
|
import {
|
|
5
9
|
GMAIL_DISPATCH_OPS,
|
|
6
10
|
BODY_PREVIEW_MAX,
|
|
11
|
+
CONFIRM_INSTRUCTION,
|
|
12
|
+
attachmentDetails,
|
|
13
|
+
attachmentPreview,
|
|
7
14
|
attachmentNames,
|
|
8
15
|
bodyPreview,
|
|
16
|
+
senderPreview,
|
|
9
17
|
extractEmails,
|
|
10
18
|
logGmailDispatch,
|
|
11
19
|
replyDispatchOp,
|
|
12
20
|
requireGmailDispatchConfirmation,
|
|
13
21
|
resultText,
|
|
14
22
|
} from '../src/gmail-dispatch-guard.js';
|
|
15
|
-
import type { GmailDispatchOp } from '../src/gmail-dispatch-guard.js';
|
|
23
|
+
import type { DispatchTokenFallback, GmailDispatchOp, TokenSubject } from '../src/gmail-dispatch-guard.js';
|
|
16
24
|
|
|
17
25
|
describe('extractEmails', () => {
|
|
18
26
|
it('extracts a bare address', () => {
|
|
@@ -159,8 +167,8 @@ function ctxDeclaring(capabilities: unknown): ServerContext {
|
|
|
159
167
|
/** claude.ai's measured shape: extensions and nothing else. */
|
|
160
168
|
const CANNOT_BE_ASKED = ctxDeclaring({ extensions: {} });
|
|
161
169
|
|
|
162
|
-
function refusalNote(op: GmailDispatchOp): string {
|
|
163
|
-
const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, op, {}) as CallToolResult;
|
|
170
|
+
async function refusalNote(op: GmailDispatchOp): Promise<string> {
|
|
171
|
+
const result = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, op, {}) as CallToolResult;
|
|
164
172
|
return JSON.parse(resultText(result)).note as string;
|
|
165
173
|
}
|
|
166
174
|
|
|
@@ -185,8 +193,8 @@ const EXPECTED_TWIN: Record<GmailDispatchOp, string | null> = {
|
|
|
185
193
|
describe('requireGmailDispatchConfirmation on a client that cannot be asked', () => {
|
|
186
194
|
it.each(GMAIL_DISPATCH_OPS.map((op) => [op, EXPECTED_TWIN[op]] as const))(
|
|
187
195
|
'answers %s with its staging twin %s',
|
|
188
|
-
(op, twin) => {
|
|
189
|
-
const note = refusalNote(op);
|
|
196
|
+
async (op, twin) => {
|
|
197
|
+
const note = await refusalNote(op);
|
|
190
198
|
expect(note).toContain('cannot show a confirmation prompt');
|
|
191
199
|
if (twin === null) {
|
|
192
200
|
// A bulk auto-reply over a search has no draft twin, and naming a tool
|
|
@@ -207,20 +215,20 @@ describe('requireGmailDispatchConfirmation on a client that cannot be asked', ()
|
|
|
207
215
|
it.each([
|
|
208
216
|
['reply', 'gog_gmail_drafts_reply'],
|
|
209
217
|
['reply-all', 'gog_gmail_drafts_reply_all'],
|
|
210
|
-
] as const)('derives the %s op from the real call path', (kind, twin) => {
|
|
211
|
-
expect(refusalNote(replyDispatchOp(kind))).toContain(`Stage it with ${twin} instead`);
|
|
218
|
+
] as const)('derives the %s op from the real call path', async (kind, twin) => {
|
|
219
|
+
expect(await refusalNote(replyDispatchOp(kind))).toContain(`Stage it with ${twin} instead`);
|
|
212
220
|
});
|
|
213
221
|
|
|
214
222
|
// Every staging twin used to point at gog_gmail_drafts_send, which now asks
|
|
215
223
|
// for confirmation too — so on a client that cannot be asked, the way through
|
|
216
224
|
// is the user sending the saved draft from Gmail themselves.
|
|
217
|
-
it('tells a client that cannot be asked that the user sends the staged draft from Gmail', () => {
|
|
218
|
-
expect(refusalNote('gmail.send')).toMatch(/send it from Gmail/);
|
|
219
|
-
expect(refusalNote('gmail.drafts-send')).toMatch(/still saved.*send it from Gmail/);
|
|
225
|
+
it('tells a client that cannot be asked that the user sends the staged draft from Gmail', async () => {
|
|
226
|
+
expect(await refusalNote('gmail.send')).toMatch(/send it from Gmail/);
|
|
227
|
+
expect(await refusalNote('gmail.drafts-send')).toMatch(/still saved.*send it from Gmail/);
|
|
220
228
|
});
|
|
221
229
|
|
|
222
|
-
it('refuses rather than dispatching, and says so in the payload', () => {
|
|
223
|
-
const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.forward', {
|
|
230
|
+
it('refuses rather than dispatching, and says so in the payload', async () => {
|
|
231
|
+
const result = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.forward', {
|
|
224
232
|
messageId: 'm1',
|
|
225
233
|
to: 'someone@example.com',
|
|
226
234
|
}) as CallToolResult;
|
|
@@ -233,8 +241,8 @@ describe('requireGmailDispatchConfirmation on a client that cannot be asked', ()
|
|
|
233
241
|
});
|
|
234
242
|
});
|
|
235
243
|
|
|
236
|
-
it('still asks a client that declares form elicitation', () => {
|
|
237
|
-
expect(requireGmailDispatchConfirmation(ctxDeclaring({ elicitation: { form: {} } }), 'gmail.forward', {}))
|
|
244
|
+
it('still asks a client that declares form elicitation', async () => {
|
|
245
|
+
expect(await requireGmailDispatchConfirmation(ctxDeclaring({ elicitation: { form: {} } }), 'gmail.forward', {}))
|
|
238
246
|
.toMatchObject({ resultType: 'input_required' });
|
|
239
247
|
});
|
|
240
248
|
});
|
|
@@ -269,3 +277,208 @@ describe('attachmentNames', () => {
|
|
|
269
277
|
expect(attachmentNames(undefined, undefined)).toEqual([]);
|
|
270
278
|
});
|
|
271
279
|
});
|
|
280
|
+
|
|
281
|
+
describe('attachmentDetails', () => {
|
|
282
|
+
it('stats a server path and measures + fingerprints inline bytes', () => {
|
|
283
|
+
const dir = mkdtempSync(join(tmpdir(), 'gct-'));
|
|
284
|
+
const path = join(dir, 'a.txt');
|
|
285
|
+
writeFileSync(path, 'hello');
|
|
286
|
+
const details = attachmentDetails([path, join(dir, 'missing.pdf')], [{ filename: 'b.png', contentBase64: Buffer.from('abc').toString('base64') }]);
|
|
287
|
+
expect(details[0]).toMatchObject({ name: path, size: 5 });
|
|
288
|
+
expect(details[0]!.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
289
|
+
expect(details[1]).toEqual({ name: join(dir, 'missing.pdf'), size: null });
|
|
290
|
+
expect(details[2]).toMatchObject({ name: 'b.png', size: 3 });
|
|
291
|
+
expect(details[2]!.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
292
|
+
expect(attachmentPreview(details)[2]).toEqual({ name: 'b.png', size: 3 });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('is empty when nothing is attached', () => {
|
|
296
|
+
expect(attachmentDetails(undefined, undefined)).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ============================================================================
|
|
301
|
+
// THE TOKEN FALLBACK (GOG_SEND_CONFIRM_FALLBACK=token). Elicitation stays the
|
|
302
|
+
// primary rail; this only replaces the REFUSAL a client that cannot be prompted
|
|
303
|
+
// would otherwise get.
|
|
304
|
+
// ============================================================================
|
|
305
|
+
describe('requireGmailDispatchConfirmation — token fallback', () => {
|
|
306
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
307
|
+
const CAN_BE_ASKED = ctxDeclaring({ elicitation: { form: {} } });
|
|
308
|
+
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
process.env = { ...ORIGINAL_ENV };
|
|
311
|
+
delete process.env.GOG_SEND_CONFIRM_FALLBACK;
|
|
312
|
+
delete process.env.GOG_CONFIRM_TTL_SECONDS;
|
|
313
|
+
delete process.env.GOG_CONFIRM_SECRET;
|
|
314
|
+
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
315
|
+
resetConfirmTokenState();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
afterEach(() => {
|
|
319
|
+
process.env = ORIGINAL_ENV;
|
|
320
|
+
vi.useRealTimers();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const subjectOf = (overrides: Partial<TokenSubject> = {}): TokenSubject => ({
|
|
324
|
+
target: 'r1',
|
|
325
|
+
revision: 'msg-1',
|
|
326
|
+
payload: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
|
|
327
|
+
preview: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
|
|
328
|
+
...overrides,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const fallback = (confirmToken?: string, subject: TokenSubject | CallToolResult = subjectOf(), tool = 'gog_gmail_drafts_send'): DispatchTokenFallback => ({
|
|
332
|
+
tool,
|
|
333
|
+
confirmToken,
|
|
334
|
+
subject: vi.fn(() => subject),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const parse = (r: unknown) => JSON.parse(resultText(r as CallToolResult));
|
|
338
|
+
|
|
339
|
+
async function phaseOne(subject = subjectOf(), tool = 'gog_gmail_drafts_send') {
|
|
340
|
+
const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(undefined, subject, tool));
|
|
341
|
+
return parse(r);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
it('with the env unset, keeps the refusal and names the switch', async () => {
|
|
345
|
+
const fb = fallback();
|
|
346
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fb));
|
|
347
|
+
expect(r.reason).toBe('confirmation-unsupported');
|
|
348
|
+
expect(r.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
|
|
349
|
+
expect(r.note).toContain('still saved');
|
|
350
|
+
expect(fb.subject).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('names the switch even for an op with no other note (autoreply)', async () => {
|
|
354
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.autoreply', {}, fallback()));
|
|
355
|
+
expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('never offers the switch to a caller that passed no fallback (a forwarding filter)', async () => {
|
|
359
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
|
|
360
|
+
const r = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.filter-forward', {}));
|
|
361
|
+
expect(r.reason).toBe('confirmation-unsupported');
|
|
362
|
+
expect(r.note).not.toContain('GOG_SEND_CONFIRM_FALLBACK');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('leaves elicitation untouched even with the env set and a token passed', async () => {
|
|
366
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
|
|
367
|
+
const fb = fallback('gct1.anything.here');
|
|
368
|
+
expect(await requireGmailDispatchConfirmation(CAN_BE_ASKED, 'gmail.drafts-send', {}, fb))
|
|
369
|
+
.toMatchObject({ resultType: 'input_required' });
|
|
370
|
+
expect(fb.subject).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('with GOG_SEND_CONFIRM_FALLBACK=token', () => {
|
|
374
|
+
beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
|
|
375
|
+
|
|
376
|
+
it('phase 1 returns the full preview, a token and the instruction, and does not proceed', async () => {
|
|
377
|
+
const r = await phaseOne();
|
|
378
|
+
expect(r).toMatchObject({
|
|
379
|
+
status: 'confirmation-required',
|
|
380
|
+
confirmed: false,
|
|
381
|
+
dispatched: false,
|
|
382
|
+
action: 'gmail.drafts-send',
|
|
383
|
+
preview: { to: 'a@example.com', bcc: 'hidden@example.com', body: 'hello' },
|
|
384
|
+
ttlSeconds: 600,
|
|
385
|
+
instruction: CONFIRM_INSTRUCTION,
|
|
386
|
+
});
|
|
387
|
+
expect(r.instruction).toBe('Show this preview to the user verbatim and send only after they explicitly approve in chat. Then call again with confirmToken.');
|
|
388
|
+
expect(r.confirmToken).toMatch(/^gct1\./);
|
|
389
|
+
expect(typeof r.expiresAt).toBe('string');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('phase 2 with a valid token proceeds (undefined)', async () => {
|
|
393
|
+
const { confirmToken } = await phaseOne();
|
|
394
|
+
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))).toBeUndefined();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('DRAFT_CHANGED on a rotated messageId, with a fresh preview and a token that works', async () => {
|
|
398
|
+
const { confirmToken } = await phaseOne();
|
|
399
|
+
const rotated = subjectOf({ revision: 'msg-2' });
|
|
400
|
+
const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, rotated));
|
|
401
|
+
expect((r as CallToolResult).isError).toBe(true);
|
|
402
|
+
const body = parse(r);
|
|
403
|
+
expect(body).toMatchObject({ status: 'confirmation-rejected', error: 'DRAFT_CHANGED', reason: 'message-id-rotated', dispatched: false, instruction: CONFIRM_INSTRUCTION });
|
|
404
|
+
expect(body.note).toMatch(/messageId/);
|
|
405
|
+
expect(body.confirmToken).not.toBe(confirmToken);
|
|
406
|
+
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(body.confirmToken, rotated))).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('DRAFT_CHANGED on a changed payload', async () => {
|
|
410
|
+
const { confirmToken } = await phaseOne();
|
|
411
|
+
const edited = subjectOf({ payload: { to: 'a@example.com', body: 'hello!' }, preview: { body: 'hello!' } });
|
|
412
|
+
const body = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, edited)));
|
|
413
|
+
expect(body).toMatchObject({ error: 'DRAFT_CHANGED', reason: 'payload-changed', preview: { body: 'hello!' } });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('TOKEN_REUSED on a second presentation', async () => {
|
|
417
|
+
const { confirmToken } = await phaseOne();
|
|
418
|
+
await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken));
|
|
419
|
+
const r = await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken));
|
|
420
|
+
expect((r as CallToolResult).isError).toBe(true);
|
|
421
|
+
expect(parse(r)).toMatchObject({ status: 'confirmation-rejected', error: 'TOKEN_REUSED', dispatched: false, action: 'gmail.drafts-send' });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('TOKEN_EXPIRED after the TTL', async () => {
|
|
425
|
+
process.env.GOG_CONFIRM_TTL_SECONDS = '60';
|
|
426
|
+
vi.useFakeTimers();
|
|
427
|
+
vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
|
|
428
|
+
const { confirmToken } = await phaseOne();
|
|
429
|
+
vi.setSystemTime(new Date('2026-09-24T10:01:01Z'));
|
|
430
|
+
expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
|
|
431
|
+
.toMatchObject({ error: 'TOKEN_EXPIRED', dispatched: false });
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('TOKEN_INVALID for a tampered token', async () => {
|
|
435
|
+
const { confirmToken } = await phaseOne();
|
|
436
|
+
const tampered = `${confirmToken.slice(0, -3)}xyz`;
|
|
437
|
+
expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(tampered))))
|
|
438
|
+
.toMatchObject({ error: 'TOKEN_INVALID' });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('TOKEN_INVALID for a token issued for a different draft', async () => {
|
|
442
|
+
const { confirmToken } = await phaseOne(subjectOf({ target: 'r-other' }));
|
|
443
|
+
expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
|
|
444
|
+
.toMatchObject({ error: 'TOKEN_INVALID' });
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('TOKEN_INVALID for a token issued by a different tool', async () => {
|
|
448
|
+
const { confirmToken } = await phaseOne(subjectOf(), 'gog_gmail_send');
|
|
449
|
+
expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken))))
|
|
450
|
+
.toMatchObject({ error: 'TOKEN_INVALID' });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('binds the account: an explicit account wins over GOG_ACCOUNT', async () => {
|
|
454
|
+
const issued = parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, { ...fallback(), account: 'other@example.com' }));
|
|
455
|
+
expect(parse(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(issued.confirmToken))))
|
|
456
|
+
.toMatchObject({ error: 'TOKEN_INVALID' });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('binds to an empty account when none is configured', async () => {
|
|
460
|
+
delete process.env.GOG_ACCOUNT;
|
|
461
|
+
const { confirmToken } = await phaseOne(subjectOf({ revision: undefined }));
|
|
462
|
+
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(confirmToken, subjectOf({ revision: undefined })))).toBeUndefined();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('passes back an error result from the subject read unchanged', async () => {
|
|
466
|
+
const failure: CallToolResult = { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
467
|
+
expect(await requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.drafts-send', {}, fallback(undefined, failure))).toBe(failure);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe('senderPreview', () => {
|
|
473
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
474
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
475
|
+
|
|
476
|
+
it('prefers an explicit alias, then the account, then GOG_ACCOUNT, then says it is the default', () => {
|
|
477
|
+
process.env = { ...ORIGINAL_ENV, GOG_ACCOUNT: 'env@example.com' };
|
|
478
|
+
expect(senderPreview('acct@example.com', 'alias@example.com')).toBe('alias@example.com');
|
|
479
|
+
expect(senderPreview('acct@example.com')).toBe('acct@example.com');
|
|
480
|
+
expect(senderPreview(undefined)).toBe('env@example.com');
|
|
481
|
+
delete process.env.GOG_ACCOUNT;
|
|
482
|
+
expect(senderPreview(undefined)).toBe("the gog account's default address");
|
|
483
|
+
});
|
|
484
|
+
});
|