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/src/tools/auth.ts CHANGED
@@ -13,6 +13,20 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
13
13
  `Default: "${defaultServices}". Prefer the narrowest set you need — requesting a service whose ` +
14
14
  `Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
15
15
  `with invalid_scope.`;
16
+ // Additional raw OAuth scope URIs, appended after the service scopes gog
17
+ // derives from `services`. This exists for scopes no service selection can
18
+ // ask for: notably bigquery.readonly, which Google demands whenever a Sheets
19
+ // response CONTAINS BigQuery Connected Sheets data (gog_sheets_datasource_*)
20
+ // and which ordinary `sheets` authorization deliberately does not request.
21
+ // Same invalid_scope caveat as `services`: Google rejects the WHOLE request
22
+ // if the scope's API is not enabled on the OAuth client's project, and that
23
+ // happens in the user's browser, so this wrapper cannot catch it.
24
+ const extraScopesDescribe =
25
+ 'Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. ' +
26
+ 'Use for scopes no service covers — e.g. https://www.googleapis.com/auth/bigquery.readonly, required ' +
27
+ 'before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an ' +
28
+ 'extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE ' +
29
+ 'authorization with invalid_scope.';
16
30
  server.registerTool('gog_auth_list', {
17
31
  description:
18
32
  'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
@@ -91,10 +105,19 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
91
105
  inputSchema: {
92
106
  email: z.string().describe('Google account email to authorize'),
93
107
  services: z.string().optional().default(defaultServices).describe(servicesDescribe),
108
+ extraScopes: z.string().optional().describe(extraScopesDescribe),
94
109
  },
95
- }, async ({ email, services = defaultServices }) => {
110
+ }, async ({ email, services = defaultServices, extraScopes }) => {
96
111
  try {
97
- return rawTextResult(await run(['auth', 'add', email, '--services', services], {
112
+ const args = ['auth', 'add', email, '--services', services];
113
+ // --force-consent rides along with extraScopes and only with them. Google
114
+ // re-prompts for a NEW scope only when consent is forced; without it the
115
+ // account can come back still missing the scope, with a success message —
116
+ // the exact shape of failure the caller cannot see. The other two tools
117
+ // force consent unconditionally; this one does not, so it must be added
118
+ // here rather than assumed.
119
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, '--force-consent');
120
+ return rawTextResult(await run(args, {
98
121
  interactive: true,
99
122
  timeout: 300_000,
100
123
  }));
@@ -115,17 +138,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
115
138
  inputSchema: {
116
139
  email: z.string().describe('Google account email to authorize'),
117
140
  services: z.string().optional().default(defaultServices).describe(servicesDescribe),
141
+ extraScopes: z.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`),
118
142
  },
119
- }, async ({ email, services = defaultServices }) => {
143
+ }, async ({ email, services = defaultServices, extraScopes }) => {
120
144
  try {
121
145
  // --force-consent guarantees a refresh token even if a prior grant exists
122
146
  // (the whole point when recovering from a dead one). redactMode 'tokens'
123
147
  // keeps the consent URL's scope names intact (the shared redactor mangles
124
148
  // them) while still stripping any real token — a step-1 URL carries none.
125
- return rawTextResult(await run(
126
- ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'],
127
- { redactMode: 'tokens' },
128
- ));
149
+ const args = ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'];
150
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
151
+ return rawTextResult(await run(args, { redactMode: 'tokens' }));
129
152
  } catch (err) {
130
153
  return errorResult(errorText(err));
131
154
  }
@@ -147,13 +170,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
147
170
  services: z.string().optional().default(defaultServices).describe(
148
171
  `Services authorized — MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`,
149
172
  ),
173
+ extraScopes: z.string().optional().describe(
174
+ 'Extra OAuth scope URIs — MUST match the value passed to gog_auth_add_url, for the same reason `services` must: ' +
175
+ 'the two steps have to describe the same grant.',
176
+ ),
150
177
  },
151
- }, async ({ email, redirectUrl, services = defaultServices }) => {
178
+ }, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
152
179
  try {
153
- return rawTextResult(await run(
154
- ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
155
- '--services', services, '--force-consent'],
156
- ));
180
+ const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
181
+ '--services', services, '--force-consent'];
182
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
183
+ return rawTextResult(await run(args));
157
184
  } catch (err) {
158
185
  return errorResult(errorText(err));
159
186
  }
@@ -5,15 +5,22 @@ import { annotateTruncatedList } from '../pagination.js';
5
5
 
6
6
  export function registerCalendarTools(server: McpServer): void {
7
7
  server.registerTool('gog_calendar_events', {
8
- description: 'List calendar events. Filters can be combined (e.g. --from + --to for a range, or --today for just today). '
8
+ description: 'List calendar events. Describe the window ONE way and one way only (gog >= 0.36.0 rejects the rest as ambiguous rather than silently discarding a flag): '
9
+ + 'today on its own; or from + to; or from + days; or days on its own (a window of that many days starting today). today cannot be combined with from, to or days, and days cannot be combined with to. '
9
10
  + 'gog returns only 10 events by default, so a wide date range is USUALLY INCOMPLETE: raise max, or page with pageToken until the response carries no nextPageToken. '
10
11
  + 'A response carrying "truncated": true is an incomplete view — never conclude an event does not exist from one.',
11
12
  annotations: { readOnlyHint: true },
12
13
  inputSchema: {
13
14
  calendarId: z.string().optional().describe('Calendar ID (default: primary calendar)'),
14
15
  from: z.string().optional().describe('Start time filter (RFC3339, date, or natural language)'),
15
- to: z.string().optional().describe('End time filter (RFC3339, date, or natural language)'),
16
- today: z.boolean().optional().describe('Only show today\'s events'),
16
+ to: z.string().optional().describe('End time filter (RFC3339, date, or natural language). Mutually exclusive with today and with days.'),
17
+ // gog >= 0.36.0 (openclaw/gogcli#981). Before that release --days sat in
18
+ // a switch arm evaluated ahead of --from, so `--from 2026-09-25 --days 5`
19
+ // silently threw --from away and answered for today instead — at exit 0,
20
+ // in a well-formed table. It is only exposed here now that it means what
21
+ // it says.
22
+ days: z.number().int().positive().optional().describe('Window LENGTH in days (calendar days, DST-aware), measured from `from` when one is given and from today otherwise. Use from + days for "the week of the 25th"; days alone for "the next N days". Mutually exclusive with to and with today.'),
23
+ today: z.boolean().optional().describe('Only show today\'s events. A complete window on its own — mutually exclusive with from, to and days.'),
17
24
  query: z.string().optional().describe('Free text search within events'),
18
25
  max: z.number().int().optional().describe('Max events to return. gog defaults to 10, which silently hides the rest — raise it, or page with pageToken.'),
19
26
  pageToken: pageTokenParam,
@@ -23,11 +30,12 @@ export function registerCalendarTools(server: McpServer): void {
23
30
  timezone: z.string().optional().describe('Display timezone for event times (IANA name, e.g. America/New_York, or "local" for the system timezone). Default: each event\'s timezone, then its calendar\'s timezone.'),
24
31
  account: accountParam,
25
32
  },
26
- }, async ({ calendarId, from, to, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
33
+ }, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
27
34
  const args = ['calendar', 'events'];
28
35
  if (calendarId) args.push(calendarId);
29
36
  if (from) args.push(`--from=${from}`);
30
37
  if (to) args.push(`--to=${to}`);
38
+ if (days !== undefined) args.push(`--days=${days}`);
31
39
  if (today) args.push('--today');
32
40
  if (query) args.push(`--query=${query}`);
33
41
  if (max !== undefined) args.push(`--max=${max}`);
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
4
4
  import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
5
5
  import type { GogArg } from '../runner.js';
6
+ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
6
7
 
7
8
  export function registerGmailTools(server: McpServer): void {
8
9
  server.registerTool('gog_gmail_search', {
@@ -46,21 +47,33 @@ export function registerGmailTools(server: McpServer): void {
46
47
  });
47
48
 
48
49
  server.registerTool('gog_gmail_get', {
49
- description: 'Get a Gmail message by ID.',
50
+ description: 'Get a Gmail message by ID. For a long message, sanitizeContent is the cheapest way to keep it in context: it drops the raw MIME payload and the HTML part, which are usually the bulk of the response.',
50
51
  annotations: { readOnlyHint: true },
51
52
  inputSchema: {
52
53
  messageId: z.string().describe('Message ID'),
53
54
  format: z.enum(['full', 'metadata', 'raw']).optional().describe('Message format (default: full)'),
55
+ // Requires gog >= 0.37.0. Before that (openclaw/gogcli#992) the JSON
56
+ // carried the headers and body TWICE — once inside `message`, once
57
+ // copied to the top level — so the flag meant to shrink the payload
58
+ // enlarged it. MIN_GOG_VERSION is the guard; there is no runtime check.
59
+ sanitizeContent: z.boolean().optional().describe('Return agent-oriented sanitized content: HTML stripped, HTTP(S) URLs removed, raw Gmail payloads omitted from the JSON. The largest payload-size reduction available here. Note the URL removal is lossy — omit this when you need to follow a link out of the message.'),
54
60
  account: accountParam,
55
61
  },
56
- }, async ({ messageId, format, account }) => {
62
+ }, async ({ messageId, format, sanitizeContent, account }) => {
57
63
  const args = ['gmail', 'get', messageId];
58
64
  if (format) args.push(`--format=${format}`);
65
+ if (sanitizeContent) args.push('--sanitize-content');
59
66
  return runOrDiagnose(args, { account });
60
67
  });
61
68
 
62
69
  server.registerTool('gog_gmail_send', {
63
- description: 'Send an email. When attach is used, the JSON result echoes the attached filenames and byte sizes — check it to confirm the files were found and embedded.',
70
+ description:
71
+ 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
72
+ + '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
73
+ + 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
74
+ + 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
75
+ + '"no such file or directory". When either is used, the JSON result echoes the attached filenames '
76
+ + 'and byte sizes — check it to confirm the files were embedded.',
64
77
  annotations: { destructiveHint: true },
65
78
  inputSchema: {
66
79
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -70,10 +83,11 @@ export function registerGmailTools(server: McpServer): void {
70
83
  bcc: z.string().optional().describe('BCC recipients, comma-separated'),
71
84
  replyToMessageId: z.string().optional().describe('Message ID to reply to'),
72
85
  threadId: z.string().optional().describe('Thread ID to reply within'),
73
- attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Each file is read on the gog server (not this client), base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Keep the total under Gmail\'s ~35 MB inline-upload limit.'),
86
+ attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
87
+ attachInline: attachInlineParam,
74
88
  account: accountParam,
75
89
  },
76
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, account }) => {
90
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
77
91
  // A long body cannot ride in argv: the hosted runner caps a single arg and
78
92
  // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
79
93
  // past the shared threshold; the executor materializes the temp file.
@@ -83,6 +97,12 @@ export function registerGmailTools(server: McpServer): void {
83
97
  if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
84
98
  if (threadId) args.push(`--thread-id=${threadId}`);
85
99
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
100
+ // Same repeatable --attach flag; the executor materializes each payload to a
101
+ // temp file beside gog and substitutes its path. `args` is passed so the
102
+ // size check sees the whole request — chiefly the body, which is itself a
103
+ // file arg once it passes payloadArg's threshold and spends the same budget.
104
+ const inline = inlineAttachmentArgs('attach', attachInline, args);
105
+ args.push(...inline);
86
106
  return runOrDiagnose(args, { account });
87
107
  });
88
108
 
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.23.2'; // x-release-please-version
41
+ const VERSION = '2.25.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -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
  });