gogcli-mcp 4.2.5 → 4.4.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +1340 -262
  4. package/dist/lib.js +1381 -260
  5. package/manifest.json +9 -2
  6. package/package.json +2 -2
  7. package/server.json +2 -2
  8. package/src/arg-guard.ts +68 -0
  9. package/src/argv.ts +27 -0
  10. package/src/attachment-root.ts +111 -0
  11. package/src/attachments.ts +1 -0
  12. package/src/blob-upload.ts +4 -2
  13. package/src/dispatch-confirmation.ts +277 -0
  14. package/src/file-roots.ts +66 -0
  15. package/src/gmail-dispatch-guard.ts +70 -30
  16. package/src/gmail-results.ts +21 -2
  17. package/src/lib.ts +15 -0
  18. package/src/run-path-guard.ts +118 -0
  19. package/src/runner.ts +86 -18
  20. package/src/send-confirm-token.ts +167 -0
  21. package/src/tools/api.ts +60 -7
  22. package/src/tools/appscript.ts +12 -8
  23. package/src/tools/auth.ts +13 -3
  24. package/src/tools/calendar.ts +178 -15
  25. package/src/tools/chat.ts +94 -18
  26. package/src/tools/classroom.ts +110 -28
  27. package/src/tools/contacts.ts +5 -3
  28. package/src/tools/docs.ts +8 -6
  29. package/src/tools/drive.ts +96 -20
  30. package/src/tools/gmail.ts +175 -24
  31. package/src/tools/sheets.ts +10 -8
  32. package/src/tools/slides.ts +14 -9
  33. package/src/tools/tasks.ts +7 -5
  34. package/src/tools/utils.ts +52 -6
  35. package/tests/arg-guard.test.ts +80 -0
  36. package/tests/attachment-root.test.ts +130 -0
  37. package/tests/attachments.test.ts +8 -0
  38. package/tests/blob-upload.test.ts +4 -3
  39. package/tests/file-roots.test.ts +101 -0
  40. package/tests/gmail-dispatch-guard.test.ts +270 -11
  41. package/tests/gmail-results.test.ts +35 -1
  42. package/tests/run-path-guard.test.ts +142 -0
  43. package/tests/runner.test.ts +136 -0
  44. package/tests/send-confirm-token.test.ts +200 -0
  45. package/tests/tools/api.test.ts +74 -8
  46. package/tests/tools/appscript.test.ts +34 -8
  47. package/tests/tools/auth.test.ts +44 -17
  48. package/tests/tools/calendar.test.ts +33 -28
  49. package/tests/tools/chat.test.ts +50 -21
  50. package/tests/tools/classroom.test.ts +43 -39
  51. package/tests/tools/contacts.test.ts +3 -2
  52. package/tests/tools/dispatch-gates.test.ts +396 -0
  53. package/tests/tools/docs.test.ts +44 -15
  54. package/tests/tools/drive.test.ts +110 -20
  55. package/tests/tools/gmail-confirm-token.test.ts +274 -0
  56. package/tests/tools/gmail.test.ts +227 -29
  57. package/tests/tools/run-tool-examples.test.ts +69 -0
  58. package/tests/tools/run-vets.test.ts +131 -0
  59. package/tests/tools/sheets.test.ts +16 -15
  60. package/tests/tools/slides.test.ts +47 -11
  61. package/tests/tools/tasks.test.ts +7 -6
  62. package/tests/tools/utils.test.ts +32 -31
  63. package/vitest.config.ts +5 -0
@@ -0,0 +1,396 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createTestHarness } from '@chrischall/mcp-utils/test';
3
+ import type { McpServer, ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
4
+ import * as runner from '../../src/runner.js';
5
+ import { pos } from '../../src/argv.js';
6
+ import { resetConfirmTokenState } from '../../src/send-confirm-token.js';
7
+ import { CONFIRM_ACTION_INSTRUCTION } from '../../src/dispatch-confirmation.js';
8
+ import { registerChatTools } from '../../src/tools/chat.js';
9
+ import { registerDriveTools, shareTargetMeta } from '../../src/tools/drive.js';
10
+ import { registerClassroomTools, readCourse } from '../../src/tools/classroom.js';
11
+ import { registerCalendarTools, eventSnapshot, parseAttendees } from '../../src/tools/calendar.js';
12
+
13
+ vi.mock('../../src/runner.js');
14
+
15
+ // ============================================================================
16
+ // The dispatch rail beyond Gmail: Chat posts, Drive shares, Classroom
17
+ // announcements, guest-visible Calendar changes. Each is exercised three ways —
18
+ // a client that can be prompted, one that cannot (refusal + hint), and the
19
+ // opt-in confirmToken fallback.
20
+ // ============================================================================
21
+
22
+ const ORIGINAL_ENV = { ...process.env };
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ process.env = { ...ORIGINAL_ENV };
27
+ delete process.env.GOG_SEND_CONFIRM_FALLBACK;
28
+ delete process.env.GOG_CONFIRM_TTL_SECONDS;
29
+ delete process.env.GOG_CONFIRM_SECRET;
30
+ process.env.GOG_ACCOUNT = 'me@example.com';
31
+ resetConfirmTokenState();
32
+ });
33
+
34
+ afterEach(() => { process.env = ORIGINAL_ENV; });
35
+
36
+ type Register = (server: McpServer) => void;
37
+ const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
38
+
39
+ /** A client that CAN be prompted, answering with `answer` and recording the prompt. */
40
+ async function prompted(register: Register, answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
41
+ const seen: ElicitRequest[] = [];
42
+ const harness = await createTestHarness(register, { elicitation: async (r) => { seen.push(r); return answer; } });
43
+ const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
44
+ return { harness, seen, details };
45
+ }
46
+ const unprompted = (register: Register) => createTestHarness(register);
47
+
48
+ /** Calls whose argv starts with these words. */
49
+ const callsTo = (...words: string[]) => vi.mocked(runner.run).mock.calls.filter(([args]) =>
50
+ words.every((w, i) => args[i] === w));
51
+
52
+ /** Answer reads with `reads[<joined command words>]`, everything else with `{}`. */
53
+ function stubReads(reads: Record<string, string>) {
54
+ vi.mocked(runner.run).mockImplementation(async (args) => {
55
+ const key = (args as unknown[]).filter((a) => typeof a === 'string' && !a.startsWith('--')).join(' ');
56
+ for (const [prefix, out] of Object.entries(reads)) if (key.startsWith(prefix)) return out;
57
+ return '{"ok":true}';
58
+ });
59
+ }
60
+
61
+ describe('gog_chat_messages_send / gog_chat_dm_send', () => {
62
+ it('prompts with the space and text, then posts once accepted', async () => {
63
+ stubReads({});
64
+ const { harness, details } = await prompted(registerChatTools);
65
+ await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', text: 'Ship it', thread: 'spaces/AAA/threads/T' });
66
+ expect(details()).toMatchObject({ space: 'spaces/AAA', thread: 'spaces/AAA/threads/T', textPreview: 'Ship it', attachments: [] });
67
+ expect(callsTo('chat', 'messages', 'send')).toHaveLength(1);
68
+ });
69
+
70
+ it('posts nothing when the user declines', async () => {
71
+ const { harness } = await prompted(registerChatTools, { action: 'decline' });
72
+ const r = json(await harness.callTool('gog_chat_dm_send', { email: 'alice@example.com', text: 'hi' }));
73
+ expect(r).toMatchObject({ confirmed: false, cancelled: true, action: 'chat.dm-send' });
74
+ expect(runner.run).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('refuses a client that cannot be prompted, naming the fallback switch', async () => {
78
+ const harness = await unprompted(registerChatTools);
79
+ const r = json(await harness.callTool('gog_chat_messages_send', { space: 'spaces/AAA', text: 'x' }));
80
+ expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'chat.message-send' });
81
+ expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
82
+ expect(runner.run).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('token fallback: previews in full, posts on phase 2, refuses a changed text', async () => {
86
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
87
+ stubReads({});
88
+ const harness = await unprompted(registerChatTools);
89
+ const args = { space: 'spaces/AAA', text: 'Ship it', attachInline: [{ filename: 'a.txt', contentBase64: Buffer.from('hey').toString('base64') }] };
90
+ const p1 = json(await harness.callTool('gog_chat_messages_send', args));
91
+ expect(p1).toMatchObject({
92
+ status: 'confirmation-required',
93
+ preview: { from: 'me@example.com', space: 'spaces/AAA', text: 'Ship it', attachments: [{ name: 'a.txt', size: 3 }] },
94
+ instruction: CONFIRM_ACTION_INSTRUCTION,
95
+ });
96
+ expect(json(await harness.callTool('gog_chat_messages_send', { ...args, text: 'Ship it!', confirmToken: p1.confirmToken })))
97
+ .toMatchObject({ error: 'DRAFT_CHANGED' });
98
+ await harness.callTool('gog_chat_messages_send', { ...args, confirmToken: p1.confirmToken });
99
+ expect(callsTo('chat', 'messages', 'send')).toHaveLength(1);
100
+
101
+ const dm = json(await harness.callTool('gog_chat_dm_send', { email: 'alice@example.com', text: 'hi' }));
102
+ expect(dm.preview).toEqual({ from: 'me@example.com', to: 'alice@example.com', text: 'hi' });
103
+ await harness.callTool('gog_chat_dm_send', { email: 'alice@example.com', text: 'hi', confirmToken: dm.confirmToken });
104
+ expect(callsTo('chat', 'dm', 'send')).toHaveLength(1);
105
+ });
106
+ });
107
+
108
+ describe('gog_drive_share', () => {
109
+ const FILE = JSON.stringify({ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' } });
110
+
111
+ it('reads the file and prompts with its name, grantee and role; shares once accepted', async () => {
112
+ stubReads({ 'drive get': FILE });
113
+ const { harness, details } = await prompted(registerDriveTools);
114
+ await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'user', email: 'bob@example.com', role: 'writer' });
115
+ expect(details()).toEqual({
116
+ file: { id: 'f1', name: 'Payroll 2026.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' },
117
+ to: 'user', email: 'bob@example.com', role: 'writer', publicLink: false,
118
+ });
119
+ expect(callsTo('drive', 'share')).toHaveLength(1);
120
+ });
121
+
122
+ it('flags a public link in the prompt heading', async () => {
123
+ stubReads({ 'drive get': FILE });
124
+ const { harness, seen } = await prompted(registerDriveTools, { action: 'decline' });
125
+ await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'anyone' });
126
+ expect(seen[0]!.params.message).toMatch(/ANYONE with the link/);
127
+ expect(callsTo('drive', 'share')).toHaveLength(0);
128
+ });
129
+
130
+ it('returns a failed read without prompting or sharing', async () => {
131
+ vi.mocked(runner.run).mockRejectedValue(new Error('File not found: f1'));
132
+ const { harness, seen } = await prompted(registerDriveTools);
133
+ const r = await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'user', email: 'b@example.com' });
134
+ expect(r.isError).toBe(true);
135
+ expect(seen).toHaveLength(0);
136
+ expect(callsTo('drive', 'share')).toHaveLength(0);
137
+ });
138
+
139
+ it('refuses a client that cannot be prompted, and says how the user can share it', async () => {
140
+ stubReads({ 'drive get': FILE });
141
+ const harness = await unprompted(registerDriveTools);
142
+ const r = json(await harness.callTool('gog_drive_share', { fileId: 'f1', to: 'domain', domain: 'example.com' }));
143
+ expect(r.note).toMatch(/share it themselves from Google Drive/);
144
+ expect(r.note).toContain('GOG_SEND_CONFIRM_FALLBACK=token');
145
+ });
146
+
147
+ it('token fallback: binds the file as read — a renamed file is DRAFT_CHANGED', async () => {
148
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
149
+ stubReads({ 'drive get': FILE });
150
+ const harness = await unprompted(registerDriveTools);
151
+ const args = { fileId: 'f1', to: 'anyone' as const };
152
+ const p1 = json(await harness.callTool('gog_drive_share', args));
153
+ expect(p1.preview).toMatchObject({ file: { name: 'Payroll 2026.xlsx' }, publicLink: true, role: 'reader' });
154
+ stubReads({ 'drive get': JSON.stringify({ file: { id: 'f1', name: 'Renamed' } }) });
155
+ expect(json(await harness.callTool('gog_drive_share', { ...args, confirmToken: p1.confirmToken }))).toMatchObject({ error: 'DRAFT_CHANGED' });
156
+ stubReads({ 'drive get': FILE });
157
+ await harness.callTool('gog_drive_share', { ...args, confirmToken: p1.confirmToken });
158
+ expect(callsTo('drive', 'share')).toHaveLength(1);
159
+ expect(callsTo('drive', 'share')[0]![0]).toContain('--force');
160
+ });
161
+
162
+ it('shareTargetMeta tolerates unreadable output', () => {
163
+ expect(shareTargetMeta('nope')).toEqual({});
164
+ });
165
+ });
166
+
167
+ describe('gog_classroom_announcements_create', () => {
168
+ const COURSE = JSON.stringify({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' } });
169
+
170
+ it('posts a DRAFT without reading the course or asking', async () => {
171
+ stubReads({});
172
+ const { harness, seen } = await prompted(registerClassroomTools);
173
+ await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'x', state: 'DRAFT' });
174
+ expect(seen).toHaveLength(0);
175
+ expect(runner.run).toHaveBeenCalledTimes(1);
176
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(1);
177
+ });
178
+
179
+ it('prompts with the class, the text and when it publishes', async () => {
180
+ stubReads({ 'classroom courses get': COURSE });
181
+ const { harness, details } = await prompted(registerClassroomTools);
182
+ await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday', scheduled: '2026-09-25T08:00:00Z' });
183
+ expect(details()).toEqual({
184
+ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' },
185
+ publishes: 'at 2026-09-25T08:00:00Z',
186
+ textPreview: 'Quiz Friday',
187
+ });
188
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(1);
189
+ });
190
+
191
+ it('refuses a client that cannot be prompted, pointing at DRAFT', async () => {
192
+ stubReads({ 'classroom courses get': COURSE });
193
+ const harness = await unprompted(registerClassroomTools);
194
+ const r = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'x' }));
195
+ expect(r.note).toMatch(/state DRAFT instead/);
196
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(0);
197
+ });
198
+
199
+ it('token fallback: phase 1 previews, phase 2 posts', async () => {
200
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
201
+ stubReads({ 'classroom courses get': COURSE });
202
+ const harness = await unprompted(registerClassroomTools);
203
+ const p1 = json(await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday' }));
204
+ expect(p1.preview).toEqual({ course: { id: 'c1', name: 'Algebra II', section: 'Period 3' }, publishes: 'immediately', text: 'Quiz Friday' });
205
+ await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'Quiz Friday', confirmToken: p1.confirmToken });
206
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(1);
207
+ });
208
+
209
+ it('returns a failed course read without posting', async () => {
210
+ vi.mocked(runner.run).mockRejectedValue(new Error('course not found'));
211
+ const { harness } = await prompted(registerClassroomTools);
212
+ expect((await harness.callTool('gog_classroom_announcements_create', { courseId: 'c1', text: 'x' })).isError).toBe(true);
213
+ expect(callsTo('classroom', 'announcements', 'create')).toHaveLength(0);
214
+ });
215
+
216
+ it('readCourse names nothing for unreadable output', async () => {
217
+ vi.mocked(runner.run).mockResolvedValue('not json');
218
+ expect(await readCourse('c9', undefined)).toEqual({ course: { id: 'c9' } });
219
+ vi.mocked(runner.run).mockResolvedValue('{"course":{"name":7}}');
220
+ expect(await readCourse('c9', undefined)).toEqual({ course: { id: 'c9' } });
221
+ });
222
+ });
223
+
224
+ describe('calendar', () => {
225
+ const EVENT = (overrides: Record<string, unknown> = {}) => JSON.stringify({
226
+ event: {
227
+ id: 'e1',
228
+ etag: '"v1"',
229
+ summary: 'Budget review',
230
+ start: { dateTime: '2026-09-30T14:00:00-04:00' },
231
+ end: { dateTime: '2026-09-30T15:00:00-04:00' },
232
+ organizer: { email: 'boss@example.com' },
233
+ attendees: [
234
+ { email: 'me@example.com', self: true },
235
+ { email: 'boss@example.com' },
236
+ { email: 'room-4@resource.calendar.google.com', resource: true },
237
+ ],
238
+ ...overrides,
239
+ },
240
+ });
241
+
242
+ describe('gog_calendar_create', () => {
243
+ it('creates a guest-free event without asking', async () => {
244
+ stubReads({});
245
+ const { harness, seen } = await prompted(registerCalendarTools);
246
+ await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Focus', from: 'a', to: 'b' });
247
+ expect(seen).toHaveLength(0);
248
+ expect(callsTo('calendar', 'create')).toHaveLength(1);
249
+ });
250
+
251
+ it('treats a rooms-only attendee list as guest-free', async () => {
252
+ stubReads({});
253
+ const { harness, seen } = await prompted(registerCalendarTools);
254
+ await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Focus', from: 'a', to: 'b', attendees: 'room@r.example;resource' });
255
+ expect(seen).toHaveLength(0);
256
+ });
257
+
258
+ it('asks before putting an event on guests\' calendars', async () => {
259
+ stubReads({});
260
+ const { harness, details } = await prompted(registerCalendarTools, { action: 'decline' });
261
+ await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com;optional,bob@example.com' });
262
+ expect(details()).toMatchObject({ summary: 'Sync', guests: ['alice@example.com', 'bob@example.com'], allDay: false, withZoom: false });
263
+ expect(callsTo('calendar', 'create')).toHaveLength(0);
264
+ });
265
+
266
+ it('refuses a client that cannot be prompted, pointing at a guest-free create', async () => {
267
+ const harness = await unprompted(registerCalendarTools);
268
+ const r = json(await harness.callTool('gog_calendar_create', { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' }));
269
+ expect(r.note).toMatch(/without attendees/);
270
+ expect(runner.run).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it('token fallback: previews, then creates on phase 2', async () => {
274
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
275
+ stubReads({});
276
+ const harness = await unprompted(registerCalendarTools);
277
+ const args = { calendarId: 'primary', summary: 'Sync', from: 'a', to: 'b', attendees: 'alice@example.com' };
278
+ const p1 = json(await harness.callTool('gog_calendar_create', args));
279
+ expect(p1.preview).toMatchObject({ guests: ['alice@example.com'] });
280
+ await harness.callTool('gog_calendar_create', { ...args, confirmToken: p1.confirmToken });
281
+ expect(callsTo('calendar', 'create')).toHaveLength(1);
282
+ });
283
+ });
284
+
285
+ describe('gog_calendar_update', () => {
286
+ it('never reads or asks for a reminder-only change', async () => {
287
+ stubReads({ 'calendar event': EVENT() });
288
+ const { harness, seen } = await prompted(registerCalendarTools);
289
+ await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'e1', reminders: ['popup:10m'] });
290
+ expect(seen).toHaveLength(0);
291
+ expect(callsTo('calendar', 'event')).toHaveLength(0);
292
+ expect(callsTo('calendar', 'update')).toHaveLength(1);
293
+ });
294
+
295
+ it('updates a guest-free event without asking', async () => {
296
+ stubReads({ 'calendar event': EVENT({ attendees: [{ email: 'me@example.com', self: true }] }) });
297
+ const { harness, seen } = await prompted(registerCalendarTools);
298
+ await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'e1', summary: 'Renamed' });
299
+ expect(seen).toHaveLength(0);
300
+ expect(callsTo('calendar', 'update')).toHaveLength(1);
301
+ });
302
+
303
+ it('asks before changing an event that has guests, showing it as it stands', async () => {
304
+ stubReads({ 'calendar event': EVENT() });
305
+ const { harness, details } = await prompted(registerCalendarTools);
306
+ await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'e1', from: '2026-10-01T14:00:00-04:00', removeZoom: false });
307
+ expect(details()).toEqual({
308
+ calendarId: 'primary',
309
+ eventId: 'e1',
310
+ current: {
311
+ summary: 'Budget review',
312
+ start: '2026-09-30T14:00:00-04:00',
313
+ end: '2026-09-30T15:00:00-04:00',
314
+ organizer: 'boss@example.com',
315
+ guests: ['boss@example.com'],
316
+ },
317
+ changes: { from: '2026-10-01T14:00:00-04:00' },
318
+ });
319
+ expect(callsTo('calendar', 'update')).toHaveLength(1);
320
+ });
321
+
322
+ it('asks when a guest-free event gains guests', async () => {
323
+ stubReads({ 'calendar event': EVENT({ attendees: undefined }) });
324
+ const { harness, seen } = await prompted(registerCalendarTools, { action: 'decline' });
325
+ await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'e1', addAttendees: 'new@example.com' });
326
+ expect(seen).toHaveLength(1);
327
+ expect(callsTo('calendar', 'update')).toHaveLength(0);
328
+ });
329
+
330
+ it('returns a failed event read without updating', async () => {
331
+ vi.mocked(runner.run).mockRejectedValue(new Error('event not found'));
332
+ const { harness } = await prompted(registerCalendarTools);
333
+ expect((await harness.callTool('gog_calendar_update', { calendarId: 'primary', eventId: 'e1', summary: 'x' })).isError).toBe(true);
334
+ expect(callsTo('calendar', 'update')).toHaveLength(0);
335
+ });
336
+
337
+ it('token fallback: an event edited elsewhere between the phases (etag rotated) is DRAFT_CHANGED', async () => {
338
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
339
+ stubReads({ 'calendar event': EVENT() });
340
+ const harness = await unprompted(registerCalendarTools);
341
+ const args = { calendarId: 'primary', eventId: 'e1', summary: 'Budget review (moved)' };
342
+ const p1 = json(await harness.callTool('gog_calendar_update', args));
343
+ expect(p1.status).toBe('confirmation-required');
344
+ stubReads({ 'calendar event': EVENT({ etag: '"v2"' }) });
345
+ expect(json(await harness.callTool('gog_calendar_update', { ...args, confirmToken: p1.confirmToken })))
346
+ .toMatchObject({ error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
347
+ expect(callsTo('calendar', 'update')).toHaveLength(0);
348
+ });
349
+ });
350
+
351
+ describe('gog_calendar_respond', () => {
352
+ it('always asks, showing the event, organizer and response', async () => {
353
+ stubReads({ 'calendar event': EVENT() });
354
+ const { harness, details } = await prompted(registerCalendarTools);
355
+ await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'e1', status: 'declined', comment: 'Conflict' });
356
+ expect(details()).toMatchObject({ event: { summary: 'Budget review', organizer: 'boss@example.com' }, response: 'declined', comment: 'Conflict' });
357
+ expect(callsTo('calendar', 'respond')).toHaveLength(1);
358
+ });
359
+
360
+ it('refuses a client that cannot be prompted', async () => {
361
+ stubReads({ 'calendar event': EVENT() });
362
+ const harness = await unprompted(registerCalendarTools);
363
+ const r = json(await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'e1', status: 'accepted' }));
364
+ expect(r.note).toMatch(/respond from Google Calendar/);
365
+ expect(callsTo('calendar', 'respond')).toHaveLength(0);
366
+ });
367
+
368
+ it('returns a failed event read without responding', async () => {
369
+ vi.mocked(runner.run).mockRejectedValue(new Error('event not found'));
370
+ const { harness } = await prompted(registerCalendarTools);
371
+ expect((await harness.callTool('gog_calendar_respond', { calendarId: 'primary', eventId: 'e1', status: 'accepted' })).isError).toBe(true);
372
+ });
373
+
374
+ it('token fallback: phase 2 records the response', async () => {
375
+ process.env.GOG_SEND_CONFIRM_FALLBACK = 'token';
376
+ stubReads({ 'calendar event': EVENT() });
377
+ const harness = await unprompted(registerCalendarTools);
378
+ const args = { calendarId: 'primary', eventId: 'e1', status: 'tentative' as const };
379
+ const { confirmToken } = json(await harness.callTool('gog_calendar_respond', args));
380
+ await harness.callTool('gog_calendar_respond', { ...args, confirmToken });
381
+ expect(callsTo('calendar', 'respond')).toHaveLength(1);
382
+ expect(callsTo('calendar', 'respond')[0]![0]).toEqual(['calendar', 'respond', pos('primary'), pos('e1'), '--status=tentative']);
383
+ });
384
+ });
385
+
386
+ it('parseAttendees drops modifiers, rooms and blanks', () => {
387
+ expect(parseAttendees(undefined)).toEqual([]);
388
+ expect(parseAttendees(' a@x.com;optional , ,room@r;Resource,b@y.com;comment=hi ')).toEqual(['a@x.com', 'b@y.com']);
389
+ });
390
+
391
+ it('eventSnapshot tolerates unreadable and partial output, and all-day dates', () => {
392
+ expect(eventSnapshot('nope')).toEqual({ guests: [] });
393
+ expect(eventSnapshot('{"event":{"start":{"date":"2026-10-01"},"attendees":[{"email":5},{}],"summary":3}}'))
394
+ .toEqual({ start: '2026-10-01', guests: [] });
395
+ });
396
+ });
@@ -1,7 +1,8 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { registerDocsTools } from '../../src/tools/docs.js';
3
3
  import * as runner from '../../src/runner.js';
4
4
  import { createTestHarness } from '@chrischall/mcp-utils/test';
5
+ import { pos } from '../../src/argv.js';
5
6
 
6
7
  vi.mock('../../src/runner.js');
7
8
 
@@ -14,7 +15,7 @@ describe('gog_docs_info', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{"title":"My Doc","docId":"abc"}');
15
16
  const harness = await setupHandlers();
16
17
  const result = await harness.callTool('gog_docs_info', { docId: 'abc' });
17
- expect(runner.run).toHaveBeenCalledWith(['docs', 'info', 'abc'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'info', pos('abc')], { account: undefined });
18
19
  expect(result.content[0].text).toContain('My Doc');
19
20
  });
20
21
 
@@ -49,7 +50,7 @@ describe('gog_docs_cat', () => {
49
50
  vi.mocked(runner.run).mockResolvedValue('Hello world');
50
51
  const harness = await setupHandlers();
51
52
  const result = await harness.callTool('gog_docs_cat', { docId: 'abc' });
52
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc'], { account: undefined });
53
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc')], { account: undefined });
53
54
  expect(result.content[0].text).toBe('Hello world');
54
55
  });
55
56
 
@@ -57,14 +58,14 @@ describe('gog_docs_cat', () => {
57
58
  vi.mocked(runner.run).mockResolvedValue('text');
58
59
  const harness = await setupHandlers();
59
60
  await harness.callTool('gog_docs_cat', { docId: 'abc', account: 'other@gmail.com' });
60
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc'], { account: 'other@gmail.com' });
61
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc')], { account: 'other@gmail.com' });
61
62
  });
62
63
 
63
64
  it('appends --chips when requested', async () => {
64
65
  vi.mocked(runner.run).mockResolvedValue('Hello @person');
65
66
  const harness = await setupHandlers();
66
67
  await harness.callTool('gog_docs_cat', { docId: 'abc', chips: true });
67
- expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', 'abc', '--chips'], { account: undefined });
68
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'cat', pos('abc'), '--chips'], { account: undefined });
68
69
  });
69
70
 
70
71
  it('returns error text on failure', async () => {
@@ -80,7 +81,7 @@ describe('gog_docs_create', () => {
80
81
  vi.mocked(runner.run).mockResolvedValue('{"docId":"newid","title":"Meeting Notes"}');
81
82
  const harness = await setupHandlers();
82
83
  await harness.callTool('gog_docs_create', { title: 'Meeting Notes' });
83
- expect(runner.run).toHaveBeenCalledWith(['docs', 'create', 'Meeting Notes'], { account: undefined });
84
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'create', pos('Meeting Notes')], { account: undefined });
84
85
  });
85
86
 
86
87
  it('returns error text on failure', async () => {
@@ -96,7 +97,7 @@ describe('gog_docs_write', () => {
96
97
  vi.mocked(runner.run).mockResolvedValue('{}');
97
98
  const harness = await setupHandlers();
98
99
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'Hello world' });
99
- expect(runner.run).toHaveBeenCalledWith(['docs', 'write', 'abc', '--text=Hello world'], { account: undefined });
100
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'write', pos('abc'), '--text=Hello world'], { account: undefined });
100
101
  });
101
102
 
102
103
  it('adds --append flag when append is true', async () => {
@@ -104,7 +105,7 @@ describe('gog_docs_write', () => {
104
105
  const harness = await setupHandlers();
105
106
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'More text', append: true });
106
107
  expect(runner.run).toHaveBeenCalledWith(
107
- ['docs', 'write', 'abc', '--text=More text', '--append'],
108
+ ['docs', 'write', pos('abc'), '--text=More text', '--append'],
108
109
  { account: undefined },
109
110
  );
110
111
  });
@@ -115,7 +116,7 @@ describe('gog_docs_write', () => {
115
116
  const harness = await setupHandlers();
116
117
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'T', batch: 'b1' });
117
118
  expect(runner.run).toHaveBeenCalledWith(
118
- ['docs', 'write', 'abc', '--text=T', '--batch=b1'],
119
+ ['docs', 'write', pos('abc'), '--text=T', '--batch=b1'],
119
120
  { account: undefined },
120
121
  );
121
122
  });
@@ -126,7 +127,7 @@ describe('gog_docs_write', () => {
126
127
  const harness = await setupHandlers();
127
128
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'Rewrite', checkOrphans: true });
128
129
  expect(runner.run).toHaveBeenCalledWith(
129
- ['docs', 'write', 'abc', '--text=Rewrite', '--check-orphans'],
130
+ ['docs', 'write', pos('abc'), '--text=Rewrite', '--check-orphans'],
130
131
  { account: undefined },
131
132
  );
132
133
  });
@@ -135,7 +136,7 @@ describe('gog_docs_write', () => {
135
136
  vi.mocked(runner.run).mockResolvedValue('{}');
136
137
  const harness = await setupHandlers();
137
138
  await harness.callTool('gog_docs_write', { docId: 'abc', text: 'text', append: false });
138
- expect(runner.run).toHaveBeenCalledWith(['docs', 'write', 'abc', '--text=text'], { account: undefined });
139
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'write', pos('abc'), '--text=text'], { account: undefined });
139
140
  });
140
141
 
141
142
  // gog 0.30.0 paragraph list / indentation / spacing / keep controls
@@ -148,7 +149,7 @@ describe('gog_docs_write', () => {
148
149
  keepLinesTogether: true, keepWithNext: true,
149
150
  });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['docs', 'write', 'abc', '--text=List', '--bullets', '--bullet-preset=BULLET_DISC_CIRCLE_SQUARE',
152
+ ['docs', 'write', pos('abc'), '--text=List', '--bullets', '--bullet-preset=BULLET_DISC_CIRCLE_SQUARE',
152
153
  '--indent-start=18', '--indent-end=6', '--indent-first-line=36', '--space-above=4', '--space-below=8',
153
154
  '--keep-lines-together', '--keep-with-next'],
154
155
  { account: undefined },
@@ -163,7 +164,7 @@ describe('gog_docs_write', () => {
163
164
  keepLinesTogether: false, keepWithNext: false,
164
165
  });
165
166
  expect(runner.run).toHaveBeenCalledWith(
166
- ['docs', 'write', 'abc', '--text=Steps', '--ordered', '--no-bullets',
167
+ ['docs', 'write', pos('abc'), '--text=Steps', '--ordered', '--no-bullets',
167
168
  '--no-keep-lines-together', '--no-keep-with-next'],
168
169
  { account: undefined },
169
170
  );
@@ -182,7 +183,7 @@ describe('gog_docs_find_replace', () => {
182
183
  vi.mocked(runner.run).mockResolvedValue('{"occurrencesChanged":2}');
183
184
  const harness = await setupHandlers();
184
185
  await harness.callTool('gog_docs_find_replace', { docId: 'abc', find: 'foo', replace: 'bar' });
185
- expect(runner.run).toHaveBeenCalledWith(['docs', 'find-replace', 'abc', 'foo', 'bar'], { account: undefined });
186
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'find-replace', pos('abc'), pos('foo'), pos('bar')], { account: undefined });
186
187
  });
187
188
 
188
189
  it('returns error text on failure', async () => {
@@ -198,7 +199,7 @@ describe('gog_docs_structure', () => {
198
199
  vi.mocked(runner.run).mockResolvedValue('[1] Heading\n[2] Paragraph');
199
200
  const harness = await setupHandlers();
200
201
  const result = await harness.callTool('gog_docs_structure', { docId: 'abc' });
201
- expect(runner.run).toHaveBeenCalledWith(['docs', 'structure', 'abc'], { account: undefined });
202
+ expect(runner.run).toHaveBeenCalledWith(['docs', 'structure', pos('abc')], { account: undefined });
202
203
  expect(result.content[0].text).toContain('Heading');
203
204
  });
204
205
 
@@ -210,6 +211,34 @@ describe('gog_docs_structure', () => {
210
211
  });
211
212
  });
212
213
 
214
+ // SEC-3/SEC-4 through the escape hatch: `export --out=~/.zshrc` writes
215
+ // attacker-shaped bytes over a shell profile. GOG_FILE_ROOTS binds it too.
216
+ describe('gog_docs_run path confinement', () => {
217
+ // The suite runs with GOG_FILE_ROOTS='/'; narrow it so confinement bites.
218
+ let prevRoots: string | undefined;
219
+ beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
220
+ afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
221
+
222
+ it.each([
223
+ ['export', ['d1', '--out=~/.zshrc']],
224
+ ['export', ['d1', '--output', '~/.zshrc']],
225
+ ['insert', ['d1', '--file=/etc/passwd']],
226
+ ])('refuses docs %s %j', async (subcommand, args) => {
227
+ const harness = await setupHandlers();
228
+ const result = await harness.callTool('gog_docs_run', { subcommand, args });
229
+ expect(result.isError).toBe(true);
230
+ expect(result.content[0].text).toMatch(/outside the directories/);
231
+ expect(runner.run).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it('refuses the -f short form, which would hide the path', async () => {
235
+ const harness = await setupHandlers();
236
+ const result = await harness.callTool('gog_docs_run', { subcommand: 'insert', args: ['d1', '-f', '/etc/passwd'] });
237
+ expect(result.isError).toBe(true);
238
+ expect(runner.run).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
213
242
  describe('gog_docs_run', () => {
214
243
  it('passes raw subcommand and args to runner', async () => {
215
244
  vi.mocked(runner.run).mockResolvedValue('{}');