gogcli-mcp 2.23.2 → 2.25.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.
@@ -592,6 +592,132 @@ describe('run', () => {
592
592
  expect(tokensOnly).not.toContain('[REDACTED]');
593
593
  });
594
594
 
595
+ // ==========================================================================
596
+ // BASE64 PAYLOAD SURVIVAL — the "Invalid Base64 string" defect.
597
+ //
598
+ // `1//` is spelled entirely in the base64 alphabet, so the refresh-token
599
+ // pattern used to match inside attachment bytes and eat forward to the next
600
+ // `+` or `/`. A 72 KiB PNG is ~97k base64 chars, giving ~0.37 expected `1//`
601
+ // runs — so roughly a THIRD of all attachments came back corrupt, and the
602
+ // client rejected them with an MCP -32602 "Invalid Base64 string".
603
+ //
604
+ // It looked filename-dependent because it is content-dependent and therefore
605
+ // uncorrelated with anything visible. These tests pin the real variable.
606
+ // ==========================================================================
607
+ const isValidBase64 = (s: string): boolean => Buffer.from(s, 'base64').toString('base64') === s;
608
+
609
+ it('leaves a base64 payload containing the refresh-token spelling intact', async () => {
610
+ // `1//` sitting mid-blob, preceded by base64 characters — the exact shape
611
+ // that used to be deleted.
612
+ const payload = `AAAA1//abcdefghijklmnop${'QRSTuvwx'.repeat(4)}`;
613
+ expect(payload).toContain('1//');
614
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
615
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
616
+ expect(JSON.parse(out).contentBase64).toBe(payload);
617
+ expect(out).not.toContain('[REDACTED]');
618
+ });
619
+
620
+ it('keeps every generated attachment payload valid base64 (the ~30% failure)', async () => {
621
+ // 200 payloads at the reported sizes. Before the fix ~30% of these came back
622
+ // undecodable; the assertion is that ALL of them survive, not most.
623
+ for (let i = 0; i < 200; i += 1) {
624
+ const bytes = Buffer.alloc(4096);
625
+ // Deterministic filler that still produces `1//` runs at the natural rate:
626
+ // a counter-driven byte pattern, seeded differently per iteration.
627
+ for (let b = 0; b < bytes.length; b += 1) bytes[b] = (b * 31 + i * 7) % 256;
628
+ const payload = bytes.toString('base64');
629
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
630
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
631
+ const got = JSON.parse(out).contentBase64 as string;
632
+ expect(isValidBase64(got)).toBe(true);
633
+ expect(got).toBe(payload);
634
+ }
635
+ });
636
+
637
+ // The left-boundary anchor must not narrow detection. Every character NOT in
638
+ // the standard base64 alphabet is a delimiter a real token turns up after, and
639
+ // `=` is the one that matters most: the form-encoded spelling is not covered by
640
+ // the shared redactor's query-param rule, which requires a preceding `?`/`&`.
641
+ it.each([
642
+ ['refresh_token=1//0eFORM-ENCODED-LEAK', '1//0eFORM-ENCODED-LEAK'],
643
+ ['access_token=ya29.a0FORM-ENCODED-LEAK', 'ya29.a0FORM-ENCODED-LEAK'],
644
+ ['grant:1//0eCOLON-LEAK', '1//0eCOLON-LEAK'],
645
+ ['[1//0eBRACKET-LEAK]', '1//0eBRACKET-LEAK'],
646
+ ['token is ya29.a0SPACE-LEAK', 'ya29.a0SPACE-LEAK'],
647
+ ])('redacts a token delimited by %j', async (stdout, secret) => {
648
+ const spawner = makeSpawner(0, stdout, '');
649
+ const out = await run(['auth', 'list'], { spawner });
650
+ expect(out).not.toContain(secret);
651
+ expect(out).toContain('[REDACTED]');
652
+ });
653
+
654
+ it("redactMode 'tokens' also catches the form-encoded spelling", async () => {
655
+ // This path runs ONLY redactGoogleTokens, so the anchor is the whole defence.
656
+ const spawner = makeSpawner(0, 'refresh_token=1//0eTOKENS-MODE-LEAK', '');
657
+ const out = await run(['auth', 'add'], { spawner, redactMode: 'tokens' });
658
+ expect(out).not.toContain('1//0eTOKENS-MODE-LEAK');
659
+ expect(out).toContain('[REDACTED]');
660
+ });
661
+
662
+ it('still redacts a real refresh token, which is never welded to base64', async () => {
663
+ // The anchor must not have bought base64 survival at the cost of detection:
664
+ // a genuine token is always delimited (quote, space, `=`, `:`), so it still
665
+ // matches.
666
+ const spawner = makeSpawner(0, '{"refresh_token":"1//0eREAL-REFRESH-TOKEN"}', '');
667
+ const out = await run(['auth', 'list'], { spawner });
668
+ expect(out).not.toContain('1//0eREAL-REFRESH-TOKEN');
669
+ expect(out).toContain('[REDACTED]');
670
+ });
671
+
672
+ it('opaqueFields exempts a named blob from redaction it would otherwise fail', async () => {
673
+ // A payload that spells a Google API key by chance — still possible after
674
+ // the boundary anchor, because AIza… needs no delimiter. The field
675
+ // exemption is what covers this class rather than one pattern.
676
+ // `/` supplies the word boundary AIza… needs, and every character here is
677
+ // in the base64 alphabet — so this is a payload a real file can produce.
678
+ const payload = `AAAA/AIza${'B'.repeat(35)}/CCC`;
679
+ expect(Buffer.from(payload, 'base64').toString('base64')).toBe(payload); // genuinely valid base64
680
+ const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
681
+ const bare = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
682
+ expect(bare).toContain('[REDACTED]'); // unprotected, the shared redactor hits it
683
+
684
+ const spawner2 = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
685
+ const guarded = await run(['gmail', 'attachment', 'm1', '0'], {
686
+ spawner: spawner2,
687
+ opaqueFields: ['contentBase64'],
688
+ });
689
+ expect(JSON.parse(guarded).contentBase64).toBe(payload);
690
+ });
691
+
692
+ it('opaqueFields still redacts prose OUTSIDE the exempt field', async () => {
693
+ // The exemption is per-field, not per-response: a token in a sibling field
694
+ // must still be stripped.
695
+ const stdout = JSON.stringify({
696
+ contentBase64: 'QUJDREVGR0hJSktMTU5PUFFSU1Q=',
697
+ note: 'refreshed with 1//0eLEAK-IN-PROSE',
698
+ });
699
+ const spawner = makeSpawner(0, stdout, '');
700
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
701
+ expect(out).not.toContain('1//0eLEAK-IN-PROSE');
702
+ expect(JSON.parse(out).contentBase64).toBe('QUJDREVGR0hJSktMTU5PUFFSU1Q=');
703
+ });
704
+
705
+ it('opaqueFields does not exempt a field carrying prose rather than a blob', async () => {
706
+ // Only an all-base64 value qualifies, so naming a field cannot be used to
707
+ // smuggle a credential through in a sentence.
708
+ const stdout = JSON.stringify({ contentBase64: 'token is 1//0eSMUGGLED-TOKEN here' });
709
+ const spawner = makeSpawner(0, stdout, '');
710
+ const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
711
+ expect(out).not.toContain('1//0eSMUGGLED-TOKEN');
712
+ });
713
+
714
+ it('opaqueFields never exempts anything on the ERROR path', async () => {
715
+ const spawner = makeSpawner(1, '', 'failed for 1//0eERROR-PATH-LEAK');
716
+ await expect(
717
+ run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] }),
718
+ ).rejects.toThrow(/\[REDACTED\]/);
719
+ });
720
+
595
721
  it("redactMode 'tokens' still strips real Google tokens", async () => {
596
722
  const leak = 'url with token ya29.a0Ad52N3-STEP-LEAK and refresh 1//0eSTEP-REFRESH-LEAK';
597
723
  const spawner = makeSpawner(0, leak, '');
@@ -124,6 +124,35 @@ describe('gog_auth_add', () => {
124
124
  );
125
125
  });
126
126
 
127
+ // gog 0.37.0's Connected Sheets reads need bigquery.readonly, which no
128
+ // `services` selection covers. --force-consent is not optional alongside it:
129
+ // Google re-prompts for a NEW scope only when consent is forced, so without
130
+ // it the grant can come back missing the scope AND reporting success.
131
+ it('passes --extra-scopes with --force-consent', async () => {
132
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
133
+ const harness = await setupHandlers();
134
+ await harness.callTool('gog_auth_add', {
135
+ email: 'user@gmail.com',
136
+ services: 'sheets',
137
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
138
+ });
139
+ expect(runner.run).toHaveBeenCalledWith(
140
+ ['auth', 'add', 'user@gmail.com', '--services', 'sheets',
141
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly', '--force-consent'],
142
+ { interactive: true, timeout: 300_000 },
143
+ );
144
+ });
145
+
146
+ it('does not force consent when no extra scopes are asked for', async () => {
147
+ vi.mocked(runner.run).mockResolvedValue('Authorization successful');
148
+ const harness = await setupHandlers();
149
+ await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
150
+ expect(runner.run).toHaveBeenCalledWith(
151
+ ['auth', 'add', 'user@gmail.com', '--services', 'all'],
152
+ { interactive: true, timeout: 300_000 },
153
+ );
154
+ });
155
+
127
156
  it('returns error text on failure', async () => {
128
157
  vi.mocked(runner.run).mockRejectedValue(new Error('Auth cancelled by user'));
129
158
  const harness = await setupHandlers();
@@ -215,6 +244,21 @@ describe('gog_auth_add_url', () => {
215
244
  );
216
245
  });
217
246
 
247
+ it('appends --extra-scopes after the service scopes', async () => {
248
+ vi.mocked(runner.run).mockResolvedValue('{"auth_url":"https://x"}');
249
+ const harness = await setupHandlers();
250
+ await harness.callTool('gog_auth_add_url', {
251
+ email: 'user@gmail.com',
252
+ services: 'sheets',
253
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
254
+ });
255
+ expect(runner.run).toHaveBeenCalledWith(
256
+ ['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
257
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
258
+ { redactMode: 'tokens' },
259
+ );
260
+ });
261
+
218
262
  it('returns error text on failure', async () => {
219
263
  vi.mocked(runner.run).mockRejectedValue(new Error('client not configured'));
220
264
  const harness = await setupHandlers();
@@ -252,6 +296,22 @@ describe('gog_auth_add_complete', () => {
252
296
  );
253
297
  });
254
298
 
299
+ it('carries the same --extra-scopes as step 1', async () => {
300
+ vi.mocked(runner.run).mockResolvedValue('{"stored":true}');
301
+ const harness = await setupHandlers();
302
+ await harness.callTool('gog_auth_add_complete', {
303
+ email: 'user@gmail.com',
304
+ redirectUrl: 'http://127.0.0.1/cb?code=c&state=s',
305
+ services: 'sheets',
306
+ extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
307
+ });
308
+ expect(runner.run).toHaveBeenCalledWith(
309
+ ['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
310
+ 'http://127.0.0.1/cb?code=c&state=s', '--services', 'sheets', '--force-consent',
311
+ '--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
312
+ );
313
+ });
314
+
255
315
  it('returns error text on failure (e.g. expired state)', async () => {
256
316
  vi.mocked(runner.run).mockRejectedValue(new Error('no matching manual auth state'));
257
317
  const harness = await setupHandlers();
@@ -17,23 +17,50 @@ describe('gog_calendar_events', () => {
17
17
  expect(runner.run).toHaveBeenCalledWith(['calendar', 'events'], { account: undefined });
18
18
  });
19
19
 
20
- it('appends calendarId and filters when provided', async () => {
20
+ // The window flags are pinned as SEPARATE cases on purpose: gog >= 0.36.0
21
+ // (openclaw/gogcli#981) rejects a fixed preset combined with from/to/days,
22
+ // and days combined with to, so one test passing them all at once would
23
+ // assert an arg array gog refuses to run.
24
+ it('appends calendarId and an explicit from/to range', async () => {
21
25
  vi.mocked(runner.run).mockResolvedValue('{}');
22
26
  const harness = await setupHandlers();
23
27
  await harness.callTool('gog_calendar_events', {
24
28
  calendarId: 'primary',
25
29
  from: '2026-01-01',
26
30
  to: '2026-01-31',
27
- today: true,
28
31
  query: 'standup',
29
32
  all: true,
30
33
  });
31
34
  expect(runner.run).toHaveBeenCalledWith(
32
- ['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--today', '--query=standup', '--all'],
35
+ ['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'],
33
36
  { account: undefined },
34
37
  );
35
38
  });
36
39
 
40
+ it('appends --today on its own', async () => {
41
+ vi.mocked(runner.run).mockResolvedValue('{}');
42
+ const harness = await setupHandlers();
43
+ await harness.callTool('gog_calendar_events', { today: true });
44
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--today'], { account: undefined });
45
+ });
46
+
47
+ it('anchors --days at --from when both are given', async () => {
48
+ vi.mocked(runner.run).mockResolvedValue('{}');
49
+ const harness = await setupHandlers();
50
+ await harness.callTool('gog_calendar_events', { from: '2026-09-25', days: 5 });
51
+ expect(runner.run).toHaveBeenCalledWith(
52
+ ['calendar', 'events', '--from=2026-09-25', '--days=5'],
53
+ { account: undefined },
54
+ );
55
+ });
56
+
57
+ it('passes --days alone as a today-anchored window', async () => {
58
+ vi.mocked(runner.run).mockResolvedValue('{}');
59
+ const harness = await setupHandlers();
60
+ await harness.callTool('gog_calendar_events', { days: 7 });
61
+ expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--days=7'], { account: undefined });
62
+ });
63
+
37
64
  it('repeats --event-types for each requested type', async () => {
38
65
  vi.mocked(runner.run).mockResolvedValue('{}');
39
66
  const harness = await setupHandlers();
@@ -201,6 +201,23 @@ describe('gog_gmail_get', () => {
201
201
  expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'msg1', '--format=metadata'], { account: undefined });
202
202
  });
203
203
 
204
+ // gog >= 0.37.0 (openclaw/gogcli#992): before that release the sanitized
205
+ // JSON repeated the headers and body at the top level, so this flag grew the
206
+ // payload it exists to shrink. Pinned here as the flag spelling gog expects.
207
+ it('appends --sanitize-content when asked', async () => {
208
+ vi.mocked(runner.run).mockResolvedValue('{}');
209
+ const harness = await setupHandlers();
210
+ await harness.callTool('gog_gmail_get', { messageId: 'msg1', sanitizeContent: true });
211
+ expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'msg1', '--sanitize-content'], { account: undefined });
212
+ });
213
+
214
+ it('omits --sanitize-content when false', async () => {
215
+ vi.mocked(runner.run).mockResolvedValue('{}');
216
+ const harness = await setupHandlers();
217
+ await harness.callTool('gog_gmail_get', { messageId: 'msg1', sanitizeContent: false });
218
+ expect(runner.run).toHaveBeenCalledWith(['gmail', 'get', 'msg1'], { account: undefined });
219
+ });
220
+
204
221
  it('returns error text on failure', async () => {
205
222
  vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
206
223
  const harness = await setupHandlers();
@@ -262,6 +279,95 @@ describe('gog_gmail_send', () => {
262
279
  );
263
280
  });
264
281
 
282
+ // ==========================================================================
283
+ // INLINE ATTACHMENT BYTES — for callers with no filesystem in common with gog
284
+ // (the hosted connector, any GOG_RUNNER_URL backend). The bytes ride with the
285
+ // call and the executor materializes them beside gog.
286
+ // ==========================================================================
287
+ it('turns attachInline bytes into repeatable --attach file args', async () => {
288
+ vi.mocked(runner.run).mockResolvedValue('{}');
289
+ const harness = await setupHandlers();
290
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64');
291
+ await harness.callTool('gog_gmail_send', {
292
+ to: 'bob@example.com',
293
+ subject: 'Layouts',
294
+ body: 'See attached',
295
+ attachInline: [{ filename: 'pendant-layouts.png', contentBase64: png }],
296
+ });
297
+ expect(runner.run).toHaveBeenCalledWith(
298
+ [
299
+ 'gmail', 'send',
300
+ '--to=bob@example.com', '--subject=Layouts', '--body=See attached',
301
+ { kind: 'file', flag: 'attach', contents: png, encoding: 'base64', filename: 'pendant-layouts.png' },
302
+ ],
303
+ { account: undefined },
304
+ );
305
+ });
306
+
307
+ it('combines server-side paths and inline bytes on one message', async () => {
308
+ vi.mocked(runner.run).mockResolvedValue('{}');
309
+ const harness = await setupHandlers();
310
+ const bytes = Buffer.from('hello').toString('base64');
311
+ await harness.callTool('gog_gmail_send', {
312
+ to: 'bob@example.com',
313
+ subject: 'Both',
314
+ body: 'x',
315
+ attach: ['/tmp/on-server.pdf'],
316
+ attachInline: [{ filename: 'from-client.txt', contentBase64: bytes }],
317
+ });
318
+ const args = vi.mocked(runner.run).mock.calls[0][0];
319
+ expect(args).toContain('--attach=/tmp/on-server.pdf');
320
+ expect(args).toContainEqual(
321
+ { kind: 'file', flag: 'attach', contents: bytes, encoding: 'base64', filename: 'from-client.txt' },
322
+ );
323
+ });
324
+
325
+ it('keeps a filename with spaces and non-ASCII intact end to end', async () => {
326
+ vi.mocked(runner.run).mockResolvedValue('{}');
327
+ const harness = await setupHandlers();
328
+ const filename = 'Reçu — étude 2026.png';
329
+ await harness.callTool('gog_gmail_send', {
330
+ to: 'bob@example.com', subject: 's', body: 'b',
331
+ attachInline: [{ filename, contentBase64: Buffer.from('x').toString('base64') }],
332
+ });
333
+ const args = vi.mocked(runner.run).mock.calls[0][0];
334
+ expect(args.at(-1)).toMatchObject({ filename });
335
+ });
336
+
337
+ it('surfaces an invalid-base64 attachment as a readable error, not a corrupt send', async () => {
338
+ vi.mocked(runner.run).mockResolvedValue('{}');
339
+ const harness = await setupHandlers();
340
+ const res = await harness.callTool('gog_gmail_send', {
341
+ to: 'bob@example.com', subject: 's', body: 'b',
342
+ attachInline: [{ filename: 'a.png', contentBase64: 'not!valid!base64!' }],
343
+ });
344
+ expect(res.isError).toBe(true);
345
+ expect(res.content[0].text).toMatch(/not valid base64/);
346
+ expect(runner.run).not.toHaveBeenCalled(); // nothing was sent
347
+ });
348
+
349
+ it('rejects an oversize attachment before the call rather than deep in the stack', async () => {
350
+ vi.mocked(runner.run).mockResolvedValue('{}');
351
+ const harness = await setupHandlers();
352
+ const res = await harness.callTool('gog_gmail_send', {
353
+ to: 'bob@example.com', subject: 's', body: 'b',
354
+ attachInline: [{ filename: 'huge.bin', contentBase64: Buffer.alloc(8 * 1024 * 1024 + 1).toString('base64') }],
355
+ });
356
+ expect(res.isError).toBe(true);
357
+ expect(res.content[0].text).toMatch(/per-file limit/);
358
+ expect(runner.run).not.toHaveBeenCalled();
359
+ });
360
+
361
+ it('leaves the arg list untouched when attachInline is absent', async () => {
362
+ vi.mocked(runner.run).mockResolvedValue('{}');
363
+ const harness = await setupHandlers();
364
+ await harness.callTool('gog_gmail_send', { to: 'b@e.com', subject: 'Hi', body: 'Hello' });
365
+ expect(runner.run).toHaveBeenCalledWith(
366
+ ['gmail', 'send', '--to=b@e.com', '--subject=Hi', '--body=Hello'],
367
+ { account: undefined },
368
+ );
369
+ });
370
+
265
371
  // A body over the shared threshold cannot ride in argv (the hosted runner
266
372
  // caps a single arg; Linux caps MAX_ARG_STRLEN at 128 KiB), so payloadArg
267
373
  // swaps it for a file arg the executor materializes as a temp file.