gogcli-mcp 2.24.0 → 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.
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ inlineFileArg,
4
+ inlineAttachmentArgs,
5
+ inlineAttachmentSchema,
6
+ MAX_INLINE_ATTACHMENT_BYTES,
7
+ MAX_INLINE_ATTACHMENT_TOTAL_BYTES,
8
+ MAX_REQUEST_PAYLOAD_WIRE_BYTES,
9
+ INLINE_ATTACHMENT_LIMITS_TEXT,
10
+ } from '../src/attachments.js';
11
+ import type { GogFileArg } from '../src/runner.js';
12
+
13
+ const b64 = (s: string): string => Buffer.from(s, 'utf8').toString('base64');
14
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
15
+
16
+ describe('inlineFileArg', () => {
17
+ it('turns bytes into a base64 GogFileArg carrying the caller filename', () => {
18
+ const { arg, bytes } = inlineFileArg('attach', {
19
+ filename: 'pendant-layouts.png',
20
+ contentBase64: PNG.toString('base64'),
21
+ });
22
+ expect(arg).toEqual<GogFileArg>({
23
+ kind: 'file',
24
+ flag: 'attach',
25
+ contents: PNG.toString('base64'),
26
+ encoding: 'base64',
27
+ filename: 'pendant-layouts.png',
28
+ });
29
+ expect(bytes).toBe(PNG.length);
30
+ });
31
+
32
+ it('marks a positional arg so the path is emitted bare, not as --flag=path', () => {
33
+ const { arg } = inlineFileArg('localPath', { filename: 'notes.md', contentBase64: b64('hi') }, { positional: true });
34
+ expect(arg.positional).toBe(true);
35
+ });
36
+
37
+ it('leaves positional unset by default, preserving the --flag=path shape', () => {
38
+ const { arg } = inlineFileArg('attach', { filename: 'a.txt', contentBase64: b64('hi') });
39
+ expect(arg.positional).toBeUndefined();
40
+ });
41
+
42
+ // Filenames with spaces and non-ASCII characters are the shapes the original
43
+ // report blamed for the inline-delivery failure. They must pass through here
44
+ // completely untouched — the name is what the recipient sees.
45
+ it.each([
46
+ 'Screenshot 2026-06-13 152500.png',
47
+ 'Reçu — étude, final (v2).pdf',
48
+ 'ファイル 名前.png',
49
+ "quote'and\"double.txt",
50
+ ])('accepts %j verbatim', (filename) => {
51
+ const { arg } = inlineFileArg('attach', { filename, contentBase64: b64('x') });
52
+ expect(arg.filename).toBe(filename);
53
+ });
54
+
55
+ it('rejects a filename that is a path rather than a bare name', () => {
56
+ expect(() => inlineFileArg('attach', { filename: '../../etc/passwd', contentBase64: b64('x') }))
57
+ .toThrow(/must be a bare filename, not a path/);
58
+ expect(() => inlineFileArg('attach', { filename: 'dir\\file.txt', contentBase64: b64('x') }))
59
+ .toThrow(/must be a bare filename, not a path/);
60
+ });
61
+
62
+ it('rejects a traversal, a control character, and an over-long name', () => {
63
+ expect(() => inlineFileArg('attach', { filename: '..', contentBase64: b64('x') })).toThrow(/not a usable filename/);
64
+ expect(() => inlineFileArg('attach', { filename: 'a\u0000b.txt', contentBase64: b64('x') })).toThrow(/not a usable filename/);
65
+ expect(() => inlineFileArg('attach', { filename: `${'n'.repeat(201)}.txt`, contentBase64: b64('x') })).toThrow(/not a usable filename/);
66
+ });
67
+
68
+ it('rejects content that is not valid base64 instead of writing a corrupt file', () => {
69
+ // Buffer.from is lenient and would silently DROP the bad characters, mailing
70
+ // out a truncated file. The round-trip check is what turns that into an error.
71
+ expect(() => inlineFileArg('attach', { filename: 'a.png', contentBase64: 'not!valid!base64!' }))
72
+ .toThrow(/not valid base64/);
73
+ });
74
+
75
+ it('names the offending file and the limit when one file is too large', () => {
76
+ const tooBig = Buffer.alloc(MAX_INLINE_ATTACHMENT_BYTES + 1).toString('base64');
77
+ expect(() => inlineFileArg('attach', { filename: 'huge.bin', contentBase64: tooBig }))
78
+ .toThrow(/huge\.bin[\s\S]*exceeds the \d+-byte \(8 MiB\) per-file limit/);
79
+ });
80
+
81
+ it('accepts a file exactly at the ceiling', () => {
82
+ const exact = Buffer.alloc(MAX_INLINE_ATTACHMENT_BYTES).toString('base64');
83
+ expect(() => inlineFileArg('attach', { filename: 'exact.bin', contentBase64: exact })).not.toThrow();
84
+ });
85
+
86
+ it('uses a caller-supplied label in the error, for tools whose param is not attachInline', () => {
87
+ expect(() => inlineFileArg('localPath', { filename: 'a.png', contentBase64: '!!!' }, { where: 'content' }))
88
+ .toThrow(/^content: contents are not valid base64/);
89
+ });
90
+ });
91
+
92
+ describe('inlineAttachmentArgs', () => {
93
+ it('returns nothing for undefined or an empty list, so nothing is appended', () => {
94
+ expect(inlineAttachmentArgs('attach', undefined)).toEqual([]);
95
+ expect(inlineAttachmentArgs('attach', [])).toEqual([]);
96
+ });
97
+
98
+ it('produces one repeatable arg per attachment, in order', () => {
99
+ const args = inlineAttachmentArgs('attach', [
100
+ { filename: 'a.png', contentBase64: b64('aaa') },
101
+ { filename: 'b.pdf', contentBase64: b64('bbb') },
102
+ ]);
103
+ expect(args).toHaveLength(2);
104
+ expect(args.map((a) => (a as GogFileArg).filename)).toEqual(['a.png', 'b.pdf']);
105
+ expect(args.every((a) => (a as GogFileArg).flag === 'attach')).toBe(true);
106
+ });
107
+
108
+ it('allows two attachments with the SAME name (each gets its own temp dir)', () => {
109
+ const args = inlineAttachmentArgs('attach', [
110
+ { filename: 'chart.png', contentBase64: b64('first') },
111
+ { filename: 'chart.png', contentBase64: b64('second') },
112
+ ]);
113
+ expect(args).toHaveLength(2);
114
+ expect((args[0] as GogFileArg).contents).not.toBe((args[1] as GogFileArg).contents);
115
+ });
116
+
117
+ it('enforces a per-message total on top of the per-file ceiling', () => {
118
+ // Each file is individually legal; together they are not.
119
+ const chunk = Buffer.alloc(MAX_INLINE_ATTACHMENT_BYTES).toString('base64');
120
+ const four = Array.from({ length: 4 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: chunk }));
121
+ expect(() => inlineAttachmentArgs('attach', four))
122
+ .toThrow(/This message is too large to send/);
123
+ });
124
+
125
+ // The budget belongs to the REQUEST, not to the attachments. `payloadArg`
126
+ // turns any body over 4 KiB into a GogFileArg that rides in the same JSON
127
+ // body at ~1:1, so a near-max attachment set plus a multi-MiB body overruns
128
+ // the runner even though each input is inside its own documented limit. That
129
+ // is the same invisible-transport-rejection failure the ceiling exists to
130
+ // prevent, so the sibling args are measured rather than assumed small.
131
+ it('counts the message body against the same budget as the attachments', () => {
132
+ // Three files just under the 8 MiB per-file cap, summing to just under the
133
+ // per-message total — i.e. every input inside its own documented limit.
134
+ const each = Buffer.alloc(Math.floor((MAX_INLINE_ATTACHMENT_TOTAL_BYTES - 4096) / 3)).toString('base64');
135
+ const attachments = Array.from({ length: 3 }, (_, i) => ({ filename: `big${i}.bin`, contentBase64: each }));
136
+
137
+ // Alone: fits.
138
+ expect(() => inlineAttachmentArgs('attach', attachments)).not.toThrow();
139
+
140
+ // With a 2 MiB HTML body — itself well under the 8 MiB per-file cap — it
141
+ // does not, and the error says the body is implicated.
142
+ const body: GogFileArg = { kind: 'file', flag: 'body-html-file', contents: 'x'.repeat(2 * 1024 * 1024) };
143
+ expect(() => inlineAttachmentArgs('attach', attachments, ['gmail', 'send', body]))
144
+ .toThrow(/would fit on their own; the rest of the message \(its body, mostly\) spends \d+ bytes/);
145
+ });
146
+
147
+ it('blames the files, not the body, when the attachments alone overrun', () => {
148
+ const chunk = Buffer.alloc(MAX_INLINE_ATTACHMENT_BYTES).toString('base64');
149
+ const four = Array.from({ length: 4 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: chunk }));
150
+ expect(() => inlineAttachmentArgs('attach', four, ['gmail', 'send'])).toThrow(/too large to send/);
151
+ expect(() => inlineAttachmentArgs('attach', four, ['gmail', 'send'])).not.toThrow(/would fit on their own/);
152
+ });
153
+
154
+ it('measures a base64 sibling at its wire length, not its decoded length', () => {
155
+ // A sibling that is itself binary costs its base64 spelling, which is what
156
+ // actually travels — counting decoded bytes would under-report by 25%.
157
+ const sibling: GogFileArg = {
158
+ kind: 'file',
159
+ flag: 'attach',
160
+ contents: Buffer.alloc(MAX_INLINE_ATTACHMENT_BYTES).toString('base64'),
161
+ encoding: 'base64',
162
+ filename: 'already-counted.bin',
163
+ };
164
+ const each = Buffer.alloc(Math.floor((MAX_INLINE_ATTACHMENT_TOTAL_BYTES - 4096) / 3)).toString('base64');
165
+ const attachments = Array.from({ length: 3 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: each }));
166
+ expect(() => inlineAttachmentArgs('attach', attachments, ['gmail', 'send', sibling]))
167
+ .toThrow(/too large to send/);
168
+ });
169
+
170
+ it('ignores small sibling args, which the JSON reserve already covers', () => {
171
+ const chunk = Buffer.alloc(Math.floor(MAX_INLINE_ATTACHMENT_TOTAL_BYTES / 4)).toString('base64');
172
+ const two = Array.from({ length: 2 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: chunk }));
173
+ const flags = ['gmail', 'send', '--to=a@b.com', '--subject=Hi', '--body=short'];
174
+ expect(() => inlineAttachmentArgs('attach', two, flags)).not.toThrow();
175
+ });
176
+
177
+ // THE INVARIANT behind the per-message ceiling, asserted rather than trusted.
178
+ //
179
+ // connector-runtime sends every payload base64-encoded inside ONE JSON body,
180
+ // and the Fly runner caps that body at MAX_BODY_BYTES. Base64 inflates by 4/3,
181
+ // so a ceiling expressed in decoded bytes has to be derived from the wire cap
182
+ // or it documents a size that gets rejected as "request body too large" — a
183
+ // transport rejection from a layer the caller cannot see, which is the exact
184
+ // failure the tool-layer check exists to prevent. A 25 MiB total encoded to
185
+ // 34,952,536 chars against a 33,554,432 cap, so the limit was unreachable.
186
+ it('keeps a full message under the Fly runner request-body cap once base64-inflated', () => {
187
+ const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024; // fly-gog-runner/server.mjs
188
+ const encodedLength = (decoded: number): number => 4 * Math.ceil(decoded / 3);
189
+
190
+ // The payload budget must leave the JSON structure room inside the cap…
191
+ expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeLessThan(RUNNER_MAX_BODY_BYTES);
192
+ expect(RUNNER_MAX_BODY_BYTES - MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeGreaterThanOrEqual(128 * 1024);
193
+ // …and a full attachment set must fit inside that budget once inflated.
194
+ expect(encodedLength(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)).toBeLessThanOrEqual(MAX_REQUEST_PAYLOAD_WIRE_BYTES);
195
+ // The advertised number must itself be sendable — floor, not round.
196
+ const advertised = Number(/(\d+) MiB in total/.exec(INLINE_ATTACHMENT_LIMITS_TEXT)![1]) * 1024 * 1024;
197
+ expect(advertised).toBeLessThanOrEqual(MAX_INLINE_ATTACHMENT_TOTAL_BYTES);
198
+ });
199
+
200
+ it('reports the ceilings it actually enforces', () => {
201
+ expect(MAX_INLINE_ATTACHMENT_BYTES).toBe(8 * 1024 * 1024);
202
+ expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBe(32 * 1024 * 1024 - 256 * 1024);
203
+ expect(MAX_INLINE_ATTACHMENT_TOTAL_BYTES).toBe(24_969_216); // wire budget × 3/4
204
+ // The documented text is derived from the constants, so it cannot drift.
205
+ expect(INLINE_ATTACHMENT_LIMITS_TEXT).toBe('up to 8 MiB per file and 23 MiB in total');
206
+ });
207
+
208
+ it('accepts a message at the advertised total', () => {
209
+ // 23 MiB across three files — the number the tool description publishes.
210
+ const chunk = Buffer.alloc(Math.floor((23 * 1024 * 1024) / 3)).toString('base64');
211
+ const three = Array.from({ length: 3 }, (_, i) => ({ filename: `f${i}.bin`, contentBase64: chunk }));
212
+ expect(() => inlineAttachmentArgs('attach', three)).not.toThrow();
213
+ });
214
+ });
215
+
216
+ describe('inlineAttachmentSchema', () => {
217
+ it('requires both filename and contentBase64', () => {
218
+ expect(inlineAttachmentSchema.safeParse({ filename: 'a.png' }).success).toBe(false);
219
+ expect(inlineAttachmentSchema.safeParse({ contentBase64: b64('x') }).success).toBe(false);
220
+ expect(inlineAttachmentSchema.safeParse({ filename: 'a.png', contentBase64: b64('x') }).success).toBe(true);
221
+ });
222
+
223
+ it('rejects empty strings, which would produce a nameless or empty attachment', () => {
224
+ expect(inlineAttachmentSchema.safeParse({ filename: '', contentBase64: b64('x') }).success).toBe(false);
225
+ expect(inlineAttachmentSchema.safeParse({ filename: 'a.png', contentBase64: '' }).success).toBe(false);
226
+ });
227
+ });
@@ -8,11 +8,13 @@ import type { Spawner } from '../src/runner.js';
8
8
  // its own file because the mock would defeat the byte-level round-trip
9
9
  // assertions in runner-file-args.test.ts.
10
10
  const mkdtemp = vi.fn(async () => '/tmp/gogcli-mcp-fake');
11
+ const mkdir = vi.fn(async () => undefined);
11
12
  const writeFile = vi.fn(async () => {});
12
13
  const rm = vi.fn(async () => {});
13
14
 
14
15
  vi.mock('node:fs/promises', () => ({
15
16
  mkdtemp: (...args: unknown[]) => mkdtemp(...(args as [])),
17
+ mkdir: (...args: unknown[]) => mkdir(...(args as [])),
16
18
  writeFile: (...args: unknown[]) => writeFile(...(args as [])),
17
19
  rm: (...args: unknown[]) => rm(...(args as [])),
18
20
  }));
@@ -36,9 +38,11 @@ const fileArg = { kind: 'file', flag: 'body-file', contents: 'payload' } as cons
36
38
  describe('temp-file materialization failures', () => {
37
39
  beforeEach(() => {
38
40
  mkdtemp.mockClear();
41
+ mkdir.mockClear();
39
42
  writeFile.mockClear();
40
43
  rm.mockClear();
41
44
  rm.mockImplementation(async () => {});
45
+ mkdir.mockImplementation(async () => undefined);
42
46
  writeFile.mockImplementation(async () => {});
43
47
  });
44
48
 
@@ -83,12 +87,53 @@ describe('temp-file materialization failures', () => {
83
87
  await expect(run(['gmail', 'send', fileArg], { spawner })).rejects.toThrow('gog: invalid draft');
84
88
  });
85
89
 
86
- it('writes the payload with mode 0600 and utf8 encoding', async () => {
90
+ it('writes the payload as owner-only utf8 bytes', async () => {
87
91
  await run(['gmail', 'send', fileArg], { spawner: okSpawner() });
88
92
  expect(writeFile).toHaveBeenCalledWith(
89
93
  expect.stringContaining('body-file.txt'),
90
- 'payload',
91
- { encoding: 'utf8', mode: 0o600 },
94
+ Buffer.from('payload', 'utf8'),
95
+ { mode: 0o600 },
92
96
  );
97
+ // Each payload lands in its own numbered subdirectory of the temp dir, so a
98
+ // caller-chosen basename can never clobber another payload's.
99
+ expect(mkdir).toHaveBeenCalledWith('/tmp/gogcli-mcp-fake/0', { recursive: true, mode: 0o700 });
100
+ });
101
+
102
+ it('decodes a base64 payload to real bytes and honours a caller filename', async () => {
103
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
104
+ await run([
105
+ 'gmail', 'send',
106
+ { kind: 'file', flag: 'attach', contents: png.toString('base64'), encoding: 'base64', filename: 'Screenshot 2026-06-13 152500.png' },
107
+ ], { spawner: okSpawner() });
108
+ expect(writeFile).toHaveBeenCalledWith(
109
+ '/tmp/gogcli-mcp-fake/0/Screenshot 2026-06-13 152500.png',
110
+ png,
111
+ { mode: 0o600 },
112
+ );
113
+ });
114
+
115
+ it('gives each file arg its own subdirectory so identical basenames survive', async () => {
116
+ const spawner = okSpawner();
117
+ await run([
118
+ 'gmail', 'send',
119
+ { kind: 'file', flag: 'attach', contents: 'YQ==', encoding: 'base64', filename: 'chart.png' },
120
+ { kind: 'file', flag: 'attach', contents: 'Yg==', encoding: 'base64', filename: 'chart.png' },
121
+ ], { spawner });
122
+ const written = writeFile.mock.calls.map((c) => c[0]);
123
+ expect(written).toEqual(['/tmp/gogcli-mcp-fake/0/chart.png', '/tmp/gogcli-mcp-fake/1/chart.png']);
124
+ // Both survive as distinct --attach values rather than one clobbering the other.
125
+ const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
126
+ expect(argv.filter((a) => a.startsWith('--attach='))).toHaveLength(2);
127
+ });
128
+
129
+ it('emits a positional file arg as a bare path, not --flag=path', async () => {
130
+ const spawner = okSpawner();
131
+ await run([
132
+ 'drive', 'upload',
133
+ { kind: 'file', flag: 'localPath', contents: 'YQ==', encoding: 'base64', filename: 'notes.md', positional: true },
134
+ ], { spawner });
135
+ const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
136
+ expect(argv).toContain('/tmp/gogcli-mcp-fake/0/notes.md');
137
+ expect(argv.some((a) => a.startsWith('--localPath='))).toBe(false);
93
138
  });
94
139
  });
@@ -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, '');
@@ -279,6 +279,95 @@ describe('gog_gmail_send', () => {
279
279
  );
280
280
  });
281
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
+
282
371
  // A body over the shared threshold cannot ride in argv (the hosted runner
283
372
  // caps a single arg; Linux caps MAX_ARG_STRLEN at 128 KiB), so payloadArg
284
373
  // swaps it for a file arg the executor materializes as a temp file.