gogcli-mcp 4.2.5 → 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 +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +270 -11
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { registerDriveTools, DRIVE_LS_COMPACT_FIELDS } from '../../src/tools/drive.js';
|
|
3
3
|
import * as runner from '../../src/runner.js';
|
|
4
4
|
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { pos } from '../../src/argv.js';
|
|
5
6
|
|
|
6
7
|
vi.mock('../../src/runner.js');
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
// The dispatch rail asks before guest-visible / outbound calls; this client accepts.
|
|
10
|
+
const setupHandlers = () => createTestHarness(registerDriveTools, {
|
|
11
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
12
|
+
});
|
|
9
13
|
|
|
10
14
|
// gog_drive_ls answers in the compact rung by default, so every call carries
|
|
11
15
|
// the mask. These keep the flag-mapping tests below about flag mapping while
|
|
@@ -111,7 +115,7 @@ describe('gog_drive_search', () => {
|
|
|
111
115
|
const harness = await setupHandlers();
|
|
112
116
|
await harness.callTool('gog_drive_search', { query: 'budget' });
|
|
113
117
|
expect(runner.run).toHaveBeenCalledWith(
|
|
114
|
-
['drive', 'search', 'budget'],
|
|
118
|
+
['drive', 'search', pos('budget')],
|
|
115
119
|
{ account: undefined, stripMedia: true },
|
|
116
120
|
);
|
|
117
121
|
});
|
|
@@ -121,7 +125,7 @@ describe('gog_drive_search', () => {
|
|
|
121
125
|
const harness = await setupHandlers();
|
|
122
126
|
await harness.callTool('gog_drive_search', { query: 'budget', view: 'full' });
|
|
123
127
|
expect(runner.run).toHaveBeenCalledWith(
|
|
124
|
-
['drive', 'search', 'budget'],
|
|
128
|
+
['drive', 'search', pos('budget')],
|
|
125
129
|
{ account: undefined, stripMedia: false },
|
|
126
130
|
);
|
|
127
131
|
});
|
|
@@ -142,14 +146,14 @@ describe('gog_drive_get', () => {
|
|
|
142
146
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
143
147
|
const harness = await setupHandlers();
|
|
144
148
|
await harness.callTool('gog_drive_get', { fileId: 'f1' });
|
|
145
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: true });
|
|
149
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: true });
|
|
146
150
|
});
|
|
147
151
|
|
|
148
152
|
it('keeps everything for the full view', async () => {
|
|
149
153
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
150
154
|
const harness = await setupHandlers();
|
|
151
155
|
await harness.callTool('gog_drive_get', { fileId: 'f1', view: 'full' });
|
|
152
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: false });
|
|
156
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: false });
|
|
153
157
|
});
|
|
154
158
|
|
|
155
159
|
|
|
@@ -166,7 +170,7 @@ describe('gog_drive_mkdir', () => {
|
|
|
166
170
|
vi.mocked(runner.run).mockResolvedValue('{"id":"new-folder"}');
|
|
167
171
|
const harness = await setupHandlers();
|
|
168
172
|
await harness.callTool('gog_drive_mkdir', { name: 'Reports' });
|
|
169
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', 'Reports'], { account: undefined });
|
|
173
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', pos('Reports')], { account: undefined });
|
|
170
174
|
});
|
|
171
175
|
|
|
172
176
|
it('returns error text on failure', async () => {
|
|
@@ -182,7 +186,7 @@ describe('gog_drive_rename', () => {
|
|
|
182
186
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
183
187
|
const harness = await setupHandlers();
|
|
184
188
|
await harness.callTool('gog_drive_rename', { fileId: 'file1', newName: 'Budget 2026' });
|
|
185
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', 'file1', 'Budget 2026'], { account: undefined });
|
|
189
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', pos('file1'), pos('Budget 2026')], { account: undefined });
|
|
186
190
|
});
|
|
187
191
|
|
|
188
192
|
it('returns error text on failure', async () => {
|
|
@@ -198,7 +202,7 @@ describe('gog_drive_move', () => {
|
|
|
198
202
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
199
203
|
const harness = await setupHandlers();
|
|
200
204
|
await harness.callTool('gog_drive_move', { fileId: 'file1', parentId: 'folder1' });
|
|
201
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'move', 'file1', '--parent=folder1'], { account: undefined });
|
|
205
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'move', pos('file1'), '--parent=folder1'], { account: undefined });
|
|
202
206
|
});
|
|
203
207
|
|
|
204
208
|
it('returns error text on failure', async () => {
|
|
@@ -214,7 +218,7 @@ describe('gog_drive_delete', () => {
|
|
|
214
218
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
215
219
|
const harness = await setupHandlers();
|
|
216
220
|
await harness.callTool('gog_drive_delete', { fileId: 'file1' });
|
|
217
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
221
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
|
|
218
222
|
});
|
|
219
223
|
|
|
220
224
|
it('appends --permanent when permanent=true', async () => {
|
|
@@ -222,7 +226,7 @@ describe('gog_drive_delete', () => {
|
|
|
222
226
|
const harness = await setupHandlers();
|
|
223
227
|
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: true });
|
|
224
228
|
expect(runner.run).toHaveBeenCalledWith(
|
|
225
|
-
['drive', 'delete', 'file1', '--permanent', '--force'],
|
|
229
|
+
['drive', 'delete', pos('file1'), '--permanent', '--force'],
|
|
226
230
|
{ account: undefined },
|
|
227
231
|
);
|
|
228
232
|
});
|
|
@@ -231,7 +235,7 @@ describe('gog_drive_delete', () => {
|
|
|
231
235
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
232
236
|
const harness = await setupHandlers();
|
|
233
237
|
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: false });
|
|
234
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
238
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
|
|
235
239
|
});
|
|
236
240
|
|
|
237
241
|
it('returns error text on failure', async () => {
|
|
@@ -248,7 +252,7 @@ describe('gog_drive_share', () => {
|
|
|
248
252
|
const harness = await setupHandlers();
|
|
249
253
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'user', email: 'bob@example.com' });
|
|
250
254
|
expect(runner.run).toHaveBeenCalledWith(
|
|
251
|
-
['drive', 'share', 'file1', '--to=user', '--email=bob@example.com'],
|
|
255
|
+
['drive', 'share', pos('file1'), '--to=user', '--email=bob@example.com'],
|
|
252
256
|
{ account: undefined },
|
|
253
257
|
);
|
|
254
258
|
});
|
|
@@ -258,7 +262,7 @@ describe('gog_drive_share', () => {
|
|
|
258
262
|
const harness = await setupHandlers();
|
|
259
263
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'domain', domain: 'example.com', role: 'writer' });
|
|
260
264
|
expect(runner.run).toHaveBeenCalledWith(
|
|
261
|
-
['drive', 'share', 'file1', '--to=domain', '--domain=example.com', '--role=writer'],
|
|
265
|
+
['drive', 'share', pos('file1'), '--to=domain', '--domain=example.com', '--role=writer'],
|
|
262
266
|
{ account: undefined },
|
|
263
267
|
);
|
|
264
268
|
});
|
|
@@ -268,7 +272,7 @@ describe('gog_drive_share', () => {
|
|
|
268
272
|
const harness = await setupHandlers();
|
|
269
273
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'anyone' });
|
|
270
274
|
expect(runner.run).toHaveBeenCalledWith(
|
|
271
|
-
['drive', 'share', 'file1', '--to=anyone', '--force'],
|
|
275
|
+
['drive', 'share', pos('file1'), '--to=anyone', '--force'],
|
|
272
276
|
{ account: undefined },
|
|
273
277
|
);
|
|
274
278
|
});
|
|
@@ -295,6 +299,58 @@ describe('gog_drive_run', () => {
|
|
|
295
299
|
const result = await harness.callTool('gog_drive_run', { subcommand: 'copy', args: [] });
|
|
296
300
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
297
301
|
});
|
|
302
|
+
|
|
303
|
+
// SEC-1 regression: `gog --readonly drive mkdir x --readonly=false` reached
|
|
304
|
+
// Google on gog 0.41.0, defeating the GOG_READONLY kill switch.
|
|
305
|
+
it('refuses --readonly=false so GOG_READONLY cannot be overridden', async () => {
|
|
306
|
+
const harness = await setupHandlers();
|
|
307
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'mkdir', args: ['x', '--readonly=false'] });
|
|
308
|
+
expect(result.isError).toBe(true);
|
|
309
|
+
expect(result.content[0].text).toMatch(/--readonly=false is not allowed/);
|
|
310
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// SEC-3/SEC-4 through the escape hatch: GOG_FILE_ROOTS must bind gog_drive_run
|
|
315
|
+
// too, or `upload <gog's credentials.json>` exfiltrates what the roots protect.
|
|
316
|
+
describe('gog_drive_run path confinement', () => {
|
|
317
|
+
// The suite runs with GOG_FILE_ROOTS='/'; narrow it so confinement bites.
|
|
318
|
+
let prevRoots: string | undefined;
|
|
319
|
+
beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
|
|
320
|
+
afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
|
|
321
|
+
|
|
322
|
+
it('refuses upload, whose local path is positional, pointing at gog_drive_upload', async () => {
|
|
323
|
+
const harness = await setupHandlers();
|
|
324
|
+
const result = await harness.callTool('gog_drive_run', {
|
|
325
|
+
subcommand: 'upload',
|
|
326
|
+
args: ['/Users/x/Library/Application Support/gogcli/credentials.json'],
|
|
327
|
+
});
|
|
328
|
+
expect(result.isError).toBe(true);
|
|
329
|
+
expect(result.content[0].text).toMatch(/gog_drive_upload/);
|
|
330
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('refuses a download --out outside GOG_FILE_ROOTS', async () => {
|
|
334
|
+
const harness = await setupHandlers();
|
|
335
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'download', args: ['f1', '--out=~/.zshrc'] });
|
|
336
|
+
expect(result.isError).toBe(true);
|
|
337
|
+
expect(result.content[0].text).toMatch(/--out "~\/.zshrc" is outside/);
|
|
338
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('refuses a --on-change hook, which runs a local command', async () => {
|
|
342
|
+
const harness = await setupHandlers();
|
|
343
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'changes', args: ['poll', '--on-change=sh -c id'] });
|
|
344
|
+
expect(result.isError).toBe(true);
|
|
345
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('still forwards a --file Drive ID, which is not a local path', async () => {
|
|
349
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
350
|
+
const harness = await setupHandlers();
|
|
351
|
+
await harness.callTool('gog_drive_run', { subcommand: 'audit', args: ['sharing', '--file=1AbC'] });
|
|
352
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'audit', 'sharing', '--file=1AbC'], { account: undefined });
|
|
353
|
+
});
|
|
298
354
|
});
|
|
299
355
|
|
|
300
356
|
describe('gog_drive_extract_text', () => {
|
|
@@ -324,7 +380,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
324
380
|
['api', 'call', 'drive', 'v3', 'files.export', '--params={"fileId":"tmpDoc1","mimeType":"text/plain"}'],
|
|
325
381
|
{ account: undefined },
|
|
326
382
|
);
|
|
327
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc1', '--permanent', '--force'], { account: undefined });
|
|
383
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc1'), '--permanent', '--force'], { account: undefined });
|
|
328
384
|
});
|
|
329
385
|
|
|
330
386
|
it('passes an ocrLanguage hint on the copy', async () => {
|
|
@@ -340,7 +396,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
340
396
|
'--params={"fileId":"pdf1","ocrLanguage":"fr"}', '--body={"name":"gogcli-ocr-pdf1","mimeType":"application/vnd.google-apps.document"}'],
|
|
341
397
|
{ account: undefined, timeout: 300_000 },
|
|
342
398
|
);
|
|
343
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc2', '--permanent', '--force'], { account: undefined });
|
|
399
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc2'), '--permanent', '--force'], { account: undefined });
|
|
344
400
|
});
|
|
345
401
|
|
|
346
402
|
it('exports a native Google Doc directly with no convert or temp cleanup', async () => {
|
|
@@ -383,7 +439,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
383
439
|
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
384
440
|
expect(result.isError).toBe(true);
|
|
385
441
|
expect(result.content[0].text).toContain('export exploded');
|
|
386
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc3', '--permanent', '--force'], { account: undefined });
|
|
442
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc3'), '--permanent', '--force'], { account: undefined });
|
|
387
443
|
});
|
|
388
444
|
|
|
389
445
|
it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
|
|
@@ -417,7 +473,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
417
473
|
.mockResolvedValueOnce('body');
|
|
418
474
|
const harness = await setupHandlers();
|
|
419
475
|
await harness.callTool('gog_drive_extract_text', { fileId: 'doc1', account: 'me@x.com' });
|
|
420
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'doc1'], { account: 'me@x.com' });
|
|
476
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('doc1')], { account: 'me@x.com' });
|
|
421
477
|
});
|
|
422
478
|
});
|
|
423
479
|
|
|
@@ -435,10 +491,44 @@ describe('gog_drive_read_bytes', () => {
|
|
|
435
491
|
expect(result.content[0].text).toContain('a.pdf');
|
|
436
492
|
expect(runner.runBinary).toHaveBeenCalledWith(
|
|
437
493
|
['api', 'call', 'drive', 'v3', 'files.get', '--params={"fileId":"f1","alt":"media"}'],
|
|
438
|
-
{ account: undefined },
|
|
494
|
+
{ account: undefined, maxOutputBytes: 8 * 1024 * 1024 },
|
|
439
495
|
);
|
|
440
496
|
});
|
|
441
497
|
|
|
498
|
+
// BUG-2: the size is known from `drive get` before a byte is fetched.
|
|
499
|
+
it('refuses a file over the inline ceiling without fetching its bytes', async () => {
|
|
500
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'backup.zip', mimeType: 'application/zip', size: '4294967296' } }));
|
|
501
|
+
const harness = await setupHandlers();
|
|
502
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
503
|
+
expect(result.isError).toBe(true);
|
|
504
|
+
expect(result.content[0].text).toMatch(/4294967296 bytes.*8 MiB/);
|
|
505
|
+
expect(result.content[0].text).toContain('gog_drive_extract_text');
|
|
506
|
+
expect(runner.runBinary).not.toHaveBeenCalled();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('names the file by id when an oversized file has no name', async () => {
|
|
510
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ size: 9 * 1024 * 1024 }));
|
|
511
|
+
const harness = await setupHandlers();
|
|
512
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f9' });
|
|
513
|
+
expect(result.content[0].text).toMatch(/^f9 is 9437184 bytes/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('treats an unparseable size as unknown and relies on the fetch cap', async () => {
|
|
517
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', size: 'n/a' } }));
|
|
518
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
|
|
519
|
+
const harness = await setupHandlers();
|
|
520
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
521
|
+
expect(result.isError).toBeFalsy();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('caps the fetch itself when Drive reports no size', async () => {
|
|
525
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', mimeType: 'application/octet-stream' } }));
|
|
526
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
|
|
527
|
+
const harness = await setupHandlers();
|
|
528
|
+
await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
529
|
+
expect(runner.runBinary).toHaveBeenCalledWith(expect.any(Array), { account: undefined, maxOutputBytes: 8 * 1024 * 1024 });
|
|
530
|
+
});
|
|
531
|
+
|
|
442
532
|
it('names the text fallback when the resource type is one hosts refuse to render', async () => {
|
|
443
533
|
// The bytes arrive fine; some hosts just drop a non-image embedded resource
|
|
444
534
|
// ("Resources of type 'application/pdf' are not currently supported"), and
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
6
|
+
import { metadataThreadId, registerGmailTools } from '../../src/tools/gmail.js';
|
|
7
|
+
import * as runner from '../../src/runner.js';
|
|
8
|
+
import { pos } from '../../src/argv.js';
|
|
9
|
+
import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
|
|
10
|
+
|
|
11
|
+
vi.mock('../../src/runner.js');
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// The two-phase confirmToken fallback on the base package's send tools,
|
|
15
|
+
// driven through the real MCP RPC path. A harness registered WITHOUT an
|
|
16
|
+
// elicitation handler is a client that declares no elicitation — claude.ai's
|
|
17
|
+
// measured shape.
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
21
|
+
let fileRoot: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
process.env = { ...ORIGINAL_ENV };
|
|
26
|
+
delete process.env.GOG_SEND_CONFIRM_FALLBACK;
|
|
27
|
+
delete process.env.GOG_CONFIRM_TTL_SECONDS;
|
|
28
|
+
delete process.env.GOG_CONFIRM_SECRET;
|
|
29
|
+
process.env.GOG_ACCOUNT = 'me@example.com';
|
|
30
|
+
fileRoot = mkdtempSync(join(tmpdir(), 'gct-roots-'));
|
|
31
|
+
process.env.GOG_FILE_ROOTS = fileRoot;
|
|
32
|
+
resetConfirmTokenState();
|
|
33
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
process.env = ORIGINAL_ENV;
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const noElicitation = () => createTestHarness(registerGmailTools);
|
|
43
|
+
const withElicitation = () => createTestHarness(registerGmailTools, {
|
|
44
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
45
|
+
});
|
|
46
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
47
|
+
const SENT = '{"id":"sent1"}';
|
|
48
|
+
|
|
49
|
+
const SEND_ARGS = {
|
|
50
|
+
to: 'alice@example.com',
|
|
51
|
+
cc: 'carol@example.com',
|
|
52
|
+
bcc: 'secret-bcc@example.com',
|
|
53
|
+
subject: 'Quarterly numbers',
|
|
54
|
+
body: 'Hi Alice,\n\nNumbers attached.\n\n— me',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const sendCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args[0] === 'gmail' && args[1] === 'send');
|
|
58
|
+
|
|
59
|
+
describe('gog_gmail_send — token fallback', () => {
|
|
60
|
+
it('elicitation supported: unchanged — prompts and sends, confirmToken or not', async () => {
|
|
61
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
|
|
62
|
+
vi.mocked(runner.run).mockResolvedValue(SENT);
|
|
63
|
+
const harness = await withElicitation();
|
|
64
|
+
const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: 'gct1.not.real' });
|
|
65
|
+
expect(result.content[0]!.text).toBe(SENT);
|
|
66
|
+
expect(sendCalls()).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('unsupported + env unset: refuses, with the new hint', async () => {
|
|
70
|
+
const harness = await noElicitation();
|
|
71
|
+
const body = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
|
|
72
|
+
expect(body).toMatchObject({ confirmed: false, dispatched: false, reason: 'confirmation-unsupported' });
|
|
73
|
+
expect(body.note).toContain('set GOG_SEND_CONFIRM_FALLBACK=token to enable two-step confirmation');
|
|
74
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('unsupported + GOG_SEND_CONFIRM_FALLBACK=token', () => {
|
|
78
|
+
beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
|
|
79
|
+
|
|
80
|
+
it('phase 1 previews everything (Bcc included, full body) and sends nothing', async () => {
|
|
81
|
+
const long = `${'x'.repeat(5000)}END`;
|
|
82
|
+
const harness = await noElicitation();
|
|
83
|
+
const body = json(await harness.callTool('gog_gmail_send', {
|
|
84
|
+
...SEND_ARGS,
|
|
85
|
+
body: long,
|
|
86
|
+
threadId: 't1',
|
|
87
|
+
replyToMessageId: 'm0',
|
|
88
|
+
attachInline: [{ filename: 'q3.csv', contentBase64: Buffer.from('a,b\n1,2\n').toString('base64') }],
|
|
89
|
+
}));
|
|
90
|
+
expect(body.status).toBe('confirmation-required');
|
|
91
|
+
expect(body.dispatched).toBe(false);
|
|
92
|
+
expect(body.preview).toEqual({
|
|
93
|
+
from: 'me@example.com',
|
|
94
|
+
to: 'alice@example.com',
|
|
95
|
+
cc: 'carol@example.com',
|
|
96
|
+
bcc: 'secret-bcc@example.com',
|
|
97
|
+
subject: 'Quarterly numbers',
|
|
98
|
+
body: long,
|
|
99
|
+
attachments: [{ name: 'q3.csv', size: 8 }],
|
|
100
|
+
threadId: 't1',
|
|
101
|
+
inReplyTo: 'm0',
|
|
102
|
+
quotesOriginal: false,
|
|
103
|
+
});
|
|
104
|
+
expect(body.confirmToken).toMatch(/^gct1\./);
|
|
105
|
+
expect(body.instruction).toMatch(/verbatim/);
|
|
106
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('phase 2 with a valid token sends, exactly once', async () => {
|
|
110
|
+
vi.mocked(runner.run).mockResolvedValue(SENT);
|
|
111
|
+
const harness = await noElicitation();
|
|
112
|
+
const { confirmToken } = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
|
|
113
|
+
const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken });
|
|
114
|
+
expect(result.content[0]!.text).toBe(SENT);
|
|
115
|
+
expect(sendCalls()).toHaveLength(1);
|
|
116
|
+
expect(sendCalls()[0]![0]).toContain('--bcc=secret-bcc@example.com');
|
|
117
|
+
|
|
118
|
+
const again = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken });
|
|
119
|
+
expect(json(again)).toMatchObject({ error: 'TOKEN_REUSED', dispatched: false });
|
|
120
|
+
expect(sendCalls()).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('phase 2 with ANY changed argument is DRAFT_CHANGED and sends nothing', async () => {
|
|
124
|
+
const harness = await noElicitation();
|
|
125
|
+
const { confirmToken } = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
|
|
126
|
+
const body = json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, bcc: 'someone-else@example.com', confirmToken }));
|
|
127
|
+
expect(body).toMatchObject({ error: 'DRAFT_CHANGED', reason: 'payload-changed', dispatched: false });
|
|
128
|
+
expect(body.preview.bcc).toBe('someone-else@example.com');
|
|
129
|
+
expect(sendCalls()).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('binds a server-side attachment by content: a same-size swap is DRAFT_CHANGED', async () => {
|
|
133
|
+
const path = join(fileRoot, 'report.txt');
|
|
134
|
+
writeFileSync(path, 'v1');
|
|
135
|
+
const harness = await noElicitation();
|
|
136
|
+
const first = json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, attach: [path] }));
|
|
137
|
+
expect(first.preview.attachments).toEqual([{ name: path, size: 2 }]);
|
|
138
|
+
writeFileSync(path, 'v2');
|
|
139
|
+
const body = json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, attach: [path], confirmToken: first.confirmToken }));
|
|
140
|
+
expect(body.error).toBe('DRAFT_CHANGED');
|
|
141
|
+
expect(sendCalls()).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects an expired token', async () => {
|
|
145
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
146
|
+
vi.setSystemTime(new Date('2026-09-24T10:00:00Z'));
|
|
147
|
+
const harness = await noElicitation();
|
|
148
|
+
const { confirmToken } = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
|
|
149
|
+
vi.setSystemTime(new Date('2026-09-24T10:10:01Z'));
|
|
150
|
+
expect(json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken }))).toMatchObject({ error: 'TOKEN_EXPIRED' });
|
|
151
|
+
expect(sendCalls()).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects a tampered token', async () => {
|
|
155
|
+
const harness = await noElicitation();
|
|
156
|
+
const { confirmToken } = json(await harness.callTool('gog_gmail_send', SEND_ARGS));
|
|
157
|
+
const result = await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken: `${confirmToken}x` });
|
|
158
|
+
expect(result.isError).toBe(true);
|
|
159
|
+
expect(json(result)).toMatchObject({ error: 'TOKEN_INVALID' });
|
|
160
|
+
expect(sendCalls()).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects a token issued to a different account', async () => {
|
|
164
|
+
const harness = await noElicitation();
|
|
165
|
+
const { confirmToken } = json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, account: 'other@example.com' }));
|
|
166
|
+
expect(json(await harness.callTool('gog_gmail_send', { ...SEND_ARGS, confirmToken }))).toMatchObject({ error: 'TOKEN_INVALID' });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe.each([
|
|
172
|
+
['gog_gmail_reply', 'reply'],
|
|
173
|
+
['gog_gmail_reply_all', 'reply-all'],
|
|
174
|
+
] as const)('%s — token fallback', (tool, subcommand) => {
|
|
175
|
+
const metadata = (messageIdHeader = '<orig@mail.example.com>') => JSON.stringify({
|
|
176
|
+
message: { id: 'm1', threadId: 't1' },
|
|
177
|
+
headers: {
|
|
178
|
+
from: 'Alice <alice@example.com>',
|
|
179
|
+
to: 'me@example.com',
|
|
180
|
+
cc: 'Carol <carol@example.com>',
|
|
181
|
+
subject: 'Contract terms',
|
|
182
|
+
message_id: messageIdHeader,
|
|
183
|
+
references: '<root@mail.example.com>',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const replyCalls = () => vi.mocked(runner.run).mock.calls.filter(([args]) => args[1] === subcommand);
|
|
187
|
+
|
|
188
|
+
beforeEach(() => { process.env.GOG_SEND_CONFIRM_FALLBACK = 'token'; });
|
|
189
|
+
|
|
190
|
+
it('phase 1 previews the resolved recipients, Bcc, full body and In-Reply-To; nothing sent', async () => {
|
|
191
|
+
vi.mocked(runner.run).mockResolvedValue(metadata());
|
|
192
|
+
const harness = await noElicitation();
|
|
193
|
+
const body = json(await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.', bcc: ['auditor@example.com'] }));
|
|
194
|
+
expect(body.status).toBe('confirmation-required');
|
|
195
|
+
expect(body.preview).toMatchObject({
|
|
196
|
+
from: 'me@example.com',
|
|
197
|
+
to: subcommand === 'reply' ? ['alice@example.com'] : ['alice@example.com', 'me@example.com'],
|
|
198
|
+
cc: subcommand === 'reply' ? [] : ['carol@example.com'],
|
|
199
|
+
bcc: ['auditor@example.com'],
|
|
200
|
+
subject: 'Re: Contract terms',
|
|
201
|
+
body: 'Agreed.',
|
|
202
|
+
quotesOriginal: true,
|
|
203
|
+
threadId: 't1',
|
|
204
|
+
inReplyTo: '<orig@mail.example.com>',
|
|
205
|
+
replyingToMessageId: 'm1',
|
|
206
|
+
});
|
|
207
|
+
expect(runner.run).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(replyCalls()).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('phase 2 re-reads the original and sends with a valid token', async () => {
|
|
212
|
+
vi.mocked(runner.run).mockResolvedValue(metadata());
|
|
213
|
+
const harness = await noElicitation();
|
|
214
|
+
const { confirmToken } = json(await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.' }));
|
|
215
|
+
vi.mocked(runner.run).mockResolvedValueOnce(metadata()).mockResolvedValueOnce(SENT);
|
|
216
|
+
const result = await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.', confirmToken });
|
|
217
|
+
expect(result.content[0]!.text).toBe(SENT);
|
|
218
|
+
expect(runner.run).toHaveBeenLastCalledWith(
|
|
219
|
+
['gmail', subcommand, pos('m1'), '--body=Agreed.', '--auto-from-addressed-alias=false'],
|
|
220
|
+
{ account: undefined },
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('refuses when the original re-reads differently (DRAFT_CHANGED)', async () => {
|
|
225
|
+
vi.mocked(runner.run).mockResolvedValue(metadata());
|
|
226
|
+
const harness = await noElicitation();
|
|
227
|
+
const { confirmToken } = json(await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.' }));
|
|
228
|
+
vi.mocked(runner.run).mockResolvedValue(metadata('<different@mail.example.com>'));
|
|
229
|
+
const body = json(await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.', confirmToken }));
|
|
230
|
+
expect(body).toMatchObject({ error: 'DRAFT_CHANGED', dispatched: false });
|
|
231
|
+
expect(replyCalls()).toHaveLength(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects a token issued for a different message', async () => {
|
|
235
|
+
vi.mocked(runner.run).mockResolvedValue(metadata());
|
|
236
|
+
const harness = await noElicitation();
|
|
237
|
+
const { confirmToken } = json(await harness.callTool(tool, { messageId: 'm-other', body: 'Agreed.' }));
|
|
238
|
+
expect(json(await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.', confirmToken }))).toMatchObject({ error: 'TOKEN_INVALID' });
|
|
239
|
+
expect(replyCalls()).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('shows the alias choice, HTML body, file-backed body and signature when set', async () => {
|
|
243
|
+
vi.mocked(runner.run).mockResolvedValue('not json');
|
|
244
|
+
const htmlPath = join(fileRoot, 'body.html');
|
|
245
|
+
writeFileSync(htmlPath, '<p>hi</p>');
|
|
246
|
+
const harness = await noElicitation();
|
|
247
|
+
const a = json(await harness.callTool(tool, { messageId: 'm1', bodyHtmlFile: htmlPath, autoFromAddressedAlias: true, signature: true, noQuote: true }));
|
|
248
|
+
expect(a.preview).toMatchObject({
|
|
249
|
+
from: 'the send-as alias the original was addressed to',
|
|
250
|
+
bodyHtmlFile: htmlPath,
|
|
251
|
+
signature: 'the Gmail signature of the sending address',
|
|
252
|
+
quotesOriginal: false,
|
|
253
|
+
});
|
|
254
|
+
expect(a.preview.threadId).toBeUndefined();
|
|
255
|
+
const b = json(await harness.callTool(tool, { messageId: 'm1', bodyHtml: '<b>x</b>', from: 'alias@example.com', signatureFrom: 'alias@example.com', remove: ['alice@example.com'] }));
|
|
256
|
+
expect(b.preview).toMatchObject({ from: 'alias@example.com', bodyHtml: '<b>x</b>', signature: 'alias@example.com' });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('elicitation supported: unchanged', async () => {
|
|
260
|
+
vi.mocked(runner.run).mockResolvedValueOnce(metadata()).mockResolvedValueOnce(metadata()).mockResolvedValueOnce(SENT);
|
|
261
|
+
const harness = await withElicitation();
|
|
262
|
+
const result = await harness.callTool(tool, { messageId: 'm1', body: 'Agreed.' });
|
|
263
|
+
expect(result.content[0]!.text).toBe(SENT);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('metadataThreadId', () => {
|
|
268
|
+
it('reads message.threadId and ignores anything else', () => {
|
|
269
|
+
expect(metadataThreadId('{"message":{"threadId":"t1"}}')).toBe('t1');
|
|
270
|
+
expect(metadataThreadId('{"message":{"threadId":5}}')).toBeUndefined();
|
|
271
|
+
expect(metadataThreadId('null')).toBeUndefined();
|
|
272
|
+
expect(metadataThreadId('not json')).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|