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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -4
- package/SKILL.md +10 -5
- package/dist/index.js +525 -57
- package/dist/lib.js +457 -18
- package/manifest.json +82 -2
- package/mint.yaml +106 -0
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/gmail-results.ts +29 -2
- package/src/lib.ts +16 -0
- package/src/runner.ts +170 -15
- package/src/server.ts +6 -0
- package/src/tools/appscript.ts +173 -0
- package/src/tools/calendar.ts +56 -2
- package/src/tools/chat.ts +253 -0
- package/src/tools/gmail.ts +17 -3
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/gmail-results.test.ts +47 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/server.test.ts +2 -0
- package/tests/tools/appscript.test.ts +159 -0
- package/tests/tools/calendar.test.ts +121 -0
- package/tests/tools/chat.test.ts +284 -0
- package/tests/tools/gmail.test.ts +89 -0
|
@@ -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
|
+
}
|
package/src/tools/gmail.ts
CHANGED
|
@@ -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:
|
|
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('
|
|
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.
|
|
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');
|