gogcli-mcp 2.24.0 → 2.26.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,253 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import {
4
+ accountParam,
5
+ runOrDiagnose,
6
+ registerRunTool,
7
+ paginationParams,
8
+ pushPaginationFlags,
9
+ } from './utils.js';
10
+ import type { GogArg } from '../runner.js';
11
+ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
12
+
13
+ // Google Chat (gog >= 0.38.0 for the mention/reaction metadata in
14
+ // `messages list`; the rest of the surface is older).
15
+ //
16
+ // TWO NAMING SYSTEMS MEET HERE, and mixing them is the mistake this module's
17
+ // descriptions exist to prevent. Chat identifies everything by RESOURCE NAME —
18
+ // `spaces/AAAA`, `spaces/AAAA/messages/BBBB`, `spaces/AAAA/threads/CCCC` — and
19
+ // those names are what every tool below wants. A bare ID (`BBBB`) is accepted
20
+ // for a message only when `space` says which space it lives in, which is why
21
+ // the reaction tools carry that extra parameter.
22
+ //
23
+ // gog_chat_spaces_find / gog_chat_dm_space are the bridges from human words
24
+ // ("the launch room", "alice@example.com") to those names; reach for one of
25
+ // them first rather than guessing a resource name.
26
+ //
27
+ // WORKSPACE-ONLY, AND THAT IS NOT A SCOPE PROBLEM: gog refuses every chat call
28
+ // on a consumer @gmail.com account with "chat requires a Google Workspace
29
+ // account (non-gmail.com)" — verified live against gog 0.38.0 — no matter which
30
+ // scopes the token carries. Re-authorizing cannot fix it, so the note below is
31
+ // appended to every description here: a model that reads "permission error" as
32
+ // "missing scope" would otherwise burn a re-auth round trip on an account that
33
+ // can never work.
34
+ export function registerChatTools(server: McpServer): void {
35
+ const workspaceOnlyNote =
36
+ ' WORKSPACE ONLY: Google Chat has no API for consumer accounts, so this fails on an @gmail.com '
37
+ + 'account with "chat requires a Google Workspace account". That is the ACCOUNT, not the token — '
38
+ + 're-authorizing or adding scopes will not help.';
39
+ const spaceParam = z.string().describe(
40
+ 'Space resource name, e.g. "spaces/AAAAsomeID" (from gog_chat_spaces_list, gog_chat_spaces_find or gog_chat_dm_space)',
41
+ );
42
+ const threadParam = z.string().optional().describe(
43
+ 'Thread resource name, e.g. "spaces/AAAA/threads/CCCC" — reply inside that thread instead of starting a new one',
44
+ );
45
+
46
+ server.registerTool('gog_chat_spaces_list', {
47
+ description:
48
+ 'List the Google Chat spaces the account belongs to — named rooms and DMs alike — with their resource names. '
49
+ + 'Start here when you do not yet have a space name; gog_chat_spaces_find is faster when you know the room\'s title.'
50
+ + workspaceOnlyNote,
51
+ annotations: { readOnlyHint: true },
52
+ inputSchema: {
53
+ ...paginationParams,
54
+ account: accountParam,
55
+ },
56
+ }, async ({ max, pageToken, page, all, account }) => {
57
+ const args = ['chat', 'spaces', 'list'];
58
+ pushPaginationFlags(args, { max, pageToken, page, all });
59
+ return runOrDiagnose(args, { account });
60
+ });
61
+
62
+ server.registerTool('gog_chat_spaces_find', {
63
+ description:
64
+ 'Find spaces whose display name matches. Substring and case-insensitive by default, which is what you want when the '
65
+ + 'user names a room approximately ("the launch room"); pass exact=true to require the whole title. DMs have no '
66
+ + 'display name — use gog_chat_dm_space to reach a person.' + workspaceOnlyNote,
67
+ annotations: { readOnlyHint: true },
68
+ inputSchema: {
69
+ displayName: z.string().describe('Space display name, or part of one'),
70
+ exact: z.boolean().optional().describe('Require an exact (still case-insensitive) match on the whole display name'),
71
+ max: z.number().int().optional().describe('Max results per page'),
72
+ account: accountParam,
73
+ },
74
+ }, async ({ displayName, exact, max, account }) => {
75
+ const args = ['chat', 'spaces', 'find', displayName];
76
+ if (exact) args.push('--exact');
77
+ if (max !== undefined) args.push(`--max=${max}`);
78
+ return runOrDiagnose(args, { account });
79
+ });
80
+
81
+ server.registerTool('gog_chat_spaces_create', {
82
+ description:
83
+ 'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified — this '
84
+ + 'is visible to other people the moment it runs, so confirm the member list before calling it.' + workspaceOnlyNote,
85
+ inputSchema: {
86
+ displayName: z.string().describe('Display name for the new space'),
87
+ members: z.array(z.string()).optional().describe('Initial members, as email addresses or "users/..." resource names'),
88
+ account: accountParam,
89
+ },
90
+ }, async ({ displayName, members, account }) => {
91
+ const args = ['chat', 'spaces', 'create', displayName];
92
+ if (members) for (const member of members) args.push(`--member=${member}`);
93
+ return runOrDiagnose(args, { account });
94
+ });
95
+
96
+ server.registerTool('gog_chat_threads_list', {
97
+ description:
98
+ 'List the threads in a space, so a reply can be targeted at an existing conversation rather than starting a new one. '
99
+ + 'Pass a thread name from here as `thread` to gog_chat_messages_send.' + workspaceOnlyNote,
100
+ annotations: { readOnlyHint: true },
101
+ inputSchema: {
102
+ space: spaceParam,
103
+ ...paginationParams,
104
+ account: accountParam,
105
+ },
106
+ }, async ({ space, max, pageToken, page, all, account }) => {
107
+ const args = ['chat', 'threads', 'list', space];
108
+ pushPaginationFlags(args, { max, pageToken, page, all });
109
+ return runOrDiagnose(args, { account });
110
+ });
111
+
112
+ server.registerTool('gog_chat_messages_list', {
113
+ description:
114
+ 'Read messages in a space. The JSON carries each message\'s @-mentions and a summary of its emoji reactions '
115
+ + '(gog >= 0.38.0) alongside the text, so "who was tagged" and "did anyone react" are answerable without extra calls. '
116
+ + 'unread=true returns only what arrived after the account last read the space — the cheap way to answer "what did I '
117
+ + 'miss". Newest-first needs an explicit order="createTime desc"; Chat\'s own default is oldest-first.'
118
+ + workspaceOnlyNote,
119
+ annotations: { readOnlyHint: true },
120
+ inputSchema: {
121
+ space: spaceParam,
122
+ thread: threadParam,
123
+ unread: z.boolean().optional().describe('Only messages posted after the account last read this space'),
124
+ order: z.enum(['createTime asc', 'createTime desc', 'lastUpdateTime asc', 'lastUpdateTime desc'])
125
+ .optional().describe('Sort order (Chat default: "createTime asc", i.e. OLDEST first — ask for "createTime desc" when you want the latest messages)'),
126
+ ...paginationParams,
127
+ account: accountParam,
128
+ },
129
+ }, async ({ space, thread, unread, order, max, pageToken, page, all, account }) => {
130
+ const args = ['chat', 'messages', 'list', space];
131
+ if (thread) args.push(`--thread=${thread}`);
132
+ if (unread) args.push('--unread');
133
+ if (order) args.push(`--order=${order}`);
134
+ pushPaginationFlags(args, { max, pageToken, page, all });
135
+ return runOrDiagnose(args, { account });
136
+ });
137
+
138
+ server.registerTool('gog_chat_messages_send', {
139
+ description:
140
+ 'Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through '
141
+ + 'this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing '
142
+ + 'conversation (from gog_chat_threads_list or a message\'s thread field); omit it to start a new one. Text supports '
143
+ + 'Chat\'s markdown-ish formatting (*bold*, _italic_, `code`).' + workspaceOnlyNote,
144
+ inputSchema: {
145
+ space: spaceParam,
146
+ text: z.string().optional().describe('Message text. Optional only when an attachment is supplied.'),
147
+ thread: threadParam,
148
+ attach: z.array(z.string()).optional().describe(
149
+ 'Attachment file paths, read WHERE GOG RUNS. On a hosted or remote deployment that is not your machine — use '
150
+ + 'attachInline there instead.',
151
+ ),
152
+ attachInline: attachInlineParam,
153
+ account: accountParam,
154
+ },
155
+ }, async ({ space, text, thread, attach, attachInline, account }) => {
156
+ if (text === undefined && !attach?.length && !attachInline?.length) {
157
+ throw new Error('A Chat message needs text, an attachment, or both.');
158
+ }
159
+ const args: GogArg[] = ['chat', 'messages', 'send', space];
160
+ if (text !== undefined) args.push(`--text=${text}`);
161
+ if (thread) args.push(`--thread=${thread}`);
162
+ if (attach) for (const path of attach) args.push(`--attach=${path}`);
163
+ // Same repeatable --attach flag; the executor materializes each payload to
164
+ // a temp file beside gog. `args` is passed so the size check sees the whole
165
+ // request, not just the attachments.
166
+ args.push(...inlineAttachmentArgs('attach', attachInline, args));
167
+ return runOrDiagnose(args, { account });
168
+ });
169
+
170
+ server.registerTool('gog_chat_dm_send', {
171
+ description:
172
+ 'Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered '
173
+ + 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send.'
174
+ + workspaceOnlyNote,
175
+ inputSchema: {
176
+ email: z.string().describe('Recipient email address'),
177
+ text: z.string().describe('Message text'),
178
+ thread: threadParam,
179
+ account: accountParam,
180
+ },
181
+ }, async ({ email, text, thread, account }) => {
182
+ const args = ['chat', 'dm', 'send', email, `--text=${text}`];
183
+ if (thread) args.push(`--thread=${thread}`);
184
+ return runOrDiagnose(args, { account });
185
+ });
186
+
187
+ server.registerTool('gog_chat_dm_space', {
188
+ description:
189
+ 'Resolve the DM space for an email address — the bridge from a person to the "spaces/..." name the message tools '
190
+ + 'want. Creates the space if none exists yet, which is silent: it does not message the person.' + workspaceOnlyNote,
191
+ inputSchema: {
192
+ email: z.string().describe('The other person\'s email address'),
193
+ account: accountParam,
194
+ },
195
+ }, async ({ email, account }) => {
196
+ return runOrDiagnose(['chat', 'dm', 'space', email], { account });
197
+ });
198
+
199
+ server.registerTool('gog_chat_reactions_list', {
200
+ description:
201
+ 'List the emoji reactions on one message, with who reacted. gog_chat_messages_list already returns a reaction '
202
+ + 'SUMMARY per message; come here when you need the individual reactors, or the reaction resource names that '
203
+ + 'gog_chat_reactions_delete takes.' + workspaceOnlyNote,
204
+ annotations: { readOnlyHint: true },
205
+ inputSchema: {
206
+ message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
207
+ space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
208
+ ...paginationParams,
209
+ account: accountParam,
210
+ },
211
+ }, async ({ message, space, max, pageToken, page, all, account }) => {
212
+ const args = ['chat', 'messages', 'reactions', 'list', message];
213
+ if (space) args.push(`--space=${space}`);
214
+ pushPaginationFlags(args, { max, pageToken, page, all });
215
+ return runOrDiagnose(args, { account });
216
+ });
217
+
218
+ server.registerTool('gog_chat_reactions_create', {
219
+ description:
220
+ 'React to a message with an emoji. Visible to the space immediately. Pass the emoji itself ("👍"), not a :shortcode:.'
221
+ + workspaceOnlyNote,
222
+ inputSchema: {
223
+ message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
224
+ emoji: z.string().describe('The emoji character to react with, e.g. "👍"'),
225
+ space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
226
+ account: accountParam,
227
+ },
228
+ }, async ({ message, emoji, space, account }) => {
229
+ const args = ['chat', 'messages', 'reactions', 'create', message, emoji];
230
+ if (space) args.push(`--space=${space}`);
231
+ return runOrDiagnose(args, { account });
232
+ });
233
+
234
+ server.registerTool('gog_chat_reactions_delete', {
235
+ description:
236
+ 'Remove one emoji reaction. Takes the REACTION\'s own resource name ("spaces/.../messages/.../reactions/..."), not '
237
+ + 'the message\'s and not the emoji — get it from gog_chat_reactions_list. An account can only remove its own reaction.'
238
+ + workspaceOnlyNote,
239
+ annotations: { destructiveHint: true },
240
+ inputSchema: {
241
+ reaction: z.string().describe('Reaction resource name, e.g. "spaces/AAAA/messages/BBBB/reactions/CCCC"'),
242
+ account: accountParam,
243
+ },
244
+ }, async ({ reaction, account }) => {
245
+ return runOrDiagnose(['chat', 'messages', 'reactions', 'delete', reaction], { account });
246
+ });
247
+
248
+ registerRunTool(server, {
249
+ service: 'chat',
250
+ examples: '"spaces", "messages", "dm"',
251
+ note: 'Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes.',
252
+ });
253
+ }
@@ -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', {
@@ -66,7 +67,13 @@ export function registerGmailTools(server: McpServer): void {
66
67
  });
67
68
 
68
69
  server.registerTool('gog_gmail_send', {
69
- 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.',
70
77
  annotations: { destructiveHint: true },
71
78
  inputSchema: {
72
79
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -76,10 +83,11 @@ export function registerGmailTools(server: McpServer): void {
76
83
  bcc: z.string().optional().describe('BCC recipients, comma-separated'),
77
84
  replyToMessageId: z.string().optional().describe('Message ID to reply to'),
78
85
  threadId: z.string().optional().describe('Thread ID to reply within'),
79
- 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,
80
88
  account: accountParam,
81
89
  },
82
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, account }) => {
90
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
83
91
  // A long body cannot ride in argv: the hosted runner caps a single arg and
84
92
  // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
85
93
  // past the shared threshold; the executor materializes the temp file.
@@ -89,6 +97,12 @@ export function registerGmailTools(server: McpServer): void {
89
97
  if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
90
98
  if (threadId) args.push(`--thread-id=${threadId}`);
91
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);
92
106
  return runOrDiagnose(args, { account });
93
107
  });
94
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.24.0'; // x-release-please-version
41
+ const VERSION = '2.26.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
+ });
@@ -255,6 +255,53 @@ describe('fetchGmailPages', () => {
255
255
  expect(runPage).toHaveBeenCalledTimes(2);
256
256
  });
257
257
 
258
+ // A REPEATED CURSOR IS A STALL, and gog 0.38.0 fixed the same class on its own
259
+ // side (openclaw/gogcli#1004) — but that fix covers gog's `--all`, and this
260
+ // walk is our own: it makes N separate single-page gog calls, so a cursor
261
+ // Google repeats comes straight back to this loop. Without a guard the walk
262
+ // re-fetches the same page and merges its items again on every remaining
263
+ // iteration, so the caller gets DUPLICATES presented as more results, which is
264
+ // worse than a short answer because nothing about the payload looks wrong.
265
+ it('stops when Google repeats a cursor, instead of re-fetching the same page', async () => {
266
+ const runPage = vi.fn()
267
+ .mockResolvedValueOnce(page(['a'], 'T1'))
268
+ .mockResolvedValueOnce(page(['b'], 'T1'));
269
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
270
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b']);
271
+ // Stopped after the repeat was seen — NOT after burning all 10 pages.
272
+ expect(runPage).toHaveBeenCalledTimes(2);
273
+ });
274
+
275
+ it('still reads as truncated after a repeated cursor', async () => {
276
+ const runPage = vi.fn()
277
+ .mockResolvedValueOnce(page(['a'], 'T1'))
278
+ .mockResolvedValueOnce(page(['b'], 'T1'));
279
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
280
+ // The walk could not reach the end, so the cursor STAYS. Dropping it would
281
+ // claim completeness we never established, and a set that reads complete is
282
+ // exactly how "that email doesn't exist" gets reported about mail that does.
283
+ expect(out.nextPageToken).toBe('T1');
284
+ });
285
+
286
+ it('detects a cursor that repeats the caller-supplied one', async () => {
287
+ const runPage = vi.fn().mockResolvedValueOnce(page(['a'], 'START'));
288
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, 'START')).content[0].text as string);
289
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
290
+ expect(out.nextPageToken).toBe('START');
291
+ expect(runPage).toHaveBeenCalledTimes(1);
292
+ });
293
+
294
+ it('detects a cursor that repeats a token from earlier in the walk, not just the previous one', async () => {
295
+ const runPage = vi.fn()
296
+ .mockResolvedValueOnce(page(['a'], 'T1'))
297
+ .mockResolvedValueOnce(page(['b'], 'T2'))
298
+ .mockResolvedValueOnce(page(['c'], 'T1'));
299
+ const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
300
+ expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
301
+ expect(out.nextPageToken).toBe('T1');
302
+ expect(runPage).toHaveBeenCalledTimes(3);
303
+ });
304
+
258
305
  it('starts from a caller-supplied cursor', async () => {
259
306
  const runPage = vi.fn().mockResolvedValue(page(['a']));
260
307
  await fetchGmailPages(runPage, 'threads', 3, 'START');