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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. 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
- const setupHandlers = () => createTestHarness(registerDriveTools);
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
+ });