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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +173 -44
- package/dist/lib.js +182 -45
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/gmail-results.ts +15 -4
- package/src/lib.ts +14 -0
- package/src/runner.ts +170 -15
- package/src/tools/auth.ts +39 -12
- package/src/tools/calendar.ts +12 -4
- package/src/tools/gmail.ts +25 -5
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/tools/auth.test.ts +60 -0
- package/tests/tools/calendar.test.ts +30 -3
- package/tests/tools/gmail.test.ts +106 -0
package/tests/runner.test.ts
CHANGED
|
@@ -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, '');
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -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
|
-
|
|
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', '--
|
|
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.
|