gogcli-mcp 4.2.5 → 4.3.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/dist/index.js +714 -216
- package/dist/lib.js +750 -216
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +56 -16
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +6 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/tools/api.ts +39 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +10 -8
- package/src/tools/chat.ts +16 -13
- package/src/tools/classroom.ts +27 -25
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +42 -18
- package/src/tools/gmail.ts +71 -11
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +46 -0
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +29 -27
- package/tests/tools/chat.test.ts +46 -20
- package/tests/tools/classroom.test.ts +39 -38
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +106 -19
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
|
@@ -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 { registerDriveTools, DRIVE_LS_COMPACT_FIELDS } from '../../src/tools/drive.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
|
|
|
@@ -111,7 +112,7 @@ describe('gog_drive_search', () => {
|
|
|
111
112
|
const harness = await setupHandlers();
|
|
112
113
|
await harness.callTool('gog_drive_search', { query: 'budget' });
|
|
113
114
|
expect(runner.run).toHaveBeenCalledWith(
|
|
114
|
-
['drive', 'search', 'budget'],
|
|
115
|
+
['drive', 'search', pos('budget')],
|
|
115
116
|
{ account: undefined, stripMedia: true },
|
|
116
117
|
);
|
|
117
118
|
});
|
|
@@ -121,7 +122,7 @@ describe('gog_drive_search', () => {
|
|
|
121
122
|
const harness = await setupHandlers();
|
|
122
123
|
await harness.callTool('gog_drive_search', { query: 'budget', view: 'full' });
|
|
123
124
|
expect(runner.run).toHaveBeenCalledWith(
|
|
124
|
-
['drive', 'search', 'budget'],
|
|
125
|
+
['drive', 'search', pos('budget')],
|
|
125
126
|
{ account: undefined, stripMedia: false },
|
|
126
127
|
);
|
|
127
128
|
});
|
|
@@ -142,14 +143,14 @@ describe('gog_drive_get', () => {
|
|
|
142
143
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
143
144
|
const harness = await setupHandlers();
|
|
144
145
|
await harness.callTool('gog_drive_get', { fileId: 'f1' });
|
|
145
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: true });
|
|
146
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: true });
|
|
146
147
|
});
|
|
147
148
|
|
|
148
149
|
it('keeps everything for the full view', async () => {
|
|
149
150
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
150
151
|
const harness = await setupHandlers();
|
|
151
152
|
await harness.callTool('gog_drive_get', { fileId: 'f1', view: 'full' });
|
|
152
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: false });
|
|
153
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('f1')], { account: undefined, stripMedia: false });
|
|
153
154
|
});
|
|
154
155
|
|
|
155
156
|
|
|
@@ -166,7 +167,7 @@ describe('gog_drive_mkdir', () => {
|
|
|
166
167
|
vi.mocked(runner.run).mockResolvedValue('{"id":"new-folder"}');
|
|
167
168
|
const harness = await setupHandlers();
|
|
168
169
|
await harness.callTool('gog_drive_mkdir', { name: 'Reports' });
|
|
169
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', 'Reports'], { account: undefined });
|
|
170
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'mkdir', pos('Reports')], { account: undefined });
|
|
170
171
|
});
|
|
171
172
|
|
|
172
173
|
it('returns error text on failure', async () => {
|
|
@@ -182,7 +183,7 @@ describe('gog_drive_rename', () => {
|
|
|
182
183
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
183
184
|
const harness = await setupHandlers();
|
|
184
185
|
await harness.callTool('gog_drive_rename', { fileId: 'file1', newName: 'Budget 2026' });
|
|
185
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', 'file1', 'Budget 2026'], { account: undefined });
|
|
186
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'rename', pos('file1'), pos('Budget 2026')], { account: undefined });
|
|
186
187
|
});
|
|
187
188
|
|
|
188
189
|
it('returns error text on failure', async () => {
|
|
@@ -198,7 +199,7 @@ describe('gog_drive_move', () => {
|
|
|
198
199
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
199
200
|
const harness = await setupHandlers();
|
|
200
201
|
await harness.callTool('gog_drive_move', { fileId: 'file1', parentId: 'folder1' });
|
|
201
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'move', 'file1', '--parent=folder1'], { account: undefined });
|
|
202
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'move', pos('file1'), '--parent=folder1'], { account: undefined });
|
|
202
203
|
});
|
|
203
204
|
|
|
204
205
|
it('returns error text on failure', async () => {
|
|
@@ -214,7 +215,7 @@ describe('gog_drive_delete', () => {
|
|
|
214
215
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
215
216
|
const harness = await setupHandlers();
|
|
216
217
|
await harness.callTool('gog_drive_delete', { fileId: 'file1' });
|
|
217
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
218
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
|
|
218
219
|
});
|
|
219
220
|
|
|
220
221
|
it('appends --permanent when permanent=true', async () => {
|
|
@@ -222,7 +223,7 @@ describe('gog_drive_delete', () => {
|
|
|
222
223
|
const harness = await setupHandlers();
|
|
223
224
|
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: true });
|
|
224
225
|
expect(runner.run).toHaveBeenCalledWith(
|
|
225
|
-
['drive', 'delete', 'file1', '--permanent', '--force'],
|
|
226
|
+
['drive', 'delete', pos('file1'), '--permanent', '--force'],
|
|
226
227
|
{ account: undefined },
|
|
227
228
|
);
|
|
228
229
|
});
|
|
@@ -231,7 +232,7 @@ describe('gog_drive_delete', () => {
|
|
|
231
232
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
232
233
|
const harness = await setupHandlers();
|
|
233
234
|
await harness.callTool('gog_drive_delete', { fileId: 'file1', permanent: false });
|
|
234
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'file1', '--force'], { account: undefined });
|
|
235
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('file1'), '--force'], { account: undefined });
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
it('returns error text on failure', async () => {
|
|
@@ -248,7 +249,7 @@ describe('gog_drive_share', () => {
|
|
|
248
249
|
const harness = await setupHandlers();
|
|
249
250
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'user', email: 'bob@example.com' });
|
|
250
251
|
expect(runner.run).toHaveBeenCalledWith(
|
|
251
|
-
['drive', 'share', 'file1', '--to=user', '--email=bob@example.com'],
|
|
252
|
+
['drive', 'share', pos('file1'), '--to=user', '--email=bob@example.com'],
|
|
252
253
|
{ account: undefined },
|
|
253
254
|
);
|
|
254
255
|
});
|
|
@@ -258,7 +259,7 @@ describe('gog_drive_share', () => {
|
|
|
258
259
|
const harness = await setupHandlers();
|
|
259
260
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'domain', domain: 'example.com', role: 'writer' });
|
|
260
261
|
expect(runner.run).toHaveBeenCalledWith(
|
|
261
|
-
['drive', 'share', 'file1', '--to=domain', '--domain=example.com', '--role=writer'],
|
|
262
|
+
['drive', 'share', pos('file1'), '--to=domain', '--domain=example.com', '--role=writer'],
|
|
262
263
|
{ account: undefined },
|
|
263
264
|
);
|
|
264
265
|
});
|
|
@@ -268,7 +269,7 @@ describe('gog_drive_share', () => {
|
|
|
268
269
|
const harness = await setupHandlers();
|
|
269
270
|
await harness.callTool('gog_drive_share', { fileId: 'file1', to: 'anyone' });
|
|
270
271
|
expect(runner.run).toHaveBeenCalledWith(
|
|
271
|
-
['drive', 'share', 'file1', '--to=anyone', '--force'],
|
|
272
|
+
['drive', 'share', pos('file1'), '--to=anyone', '--force'],
|
|
272
273
|
{ account: undefined },
|
|
273
274
|
);
|
|
274
275
|
});
|
|
@@ -295,6 +296,58 @@ describe('gog_drive_run', () => {
|
|
|
295
296
|
const result = await harness.callTool('gog_drive_run', { subcommand: 'copy', args: [] });
|
|
296
297
|
expect(result.content[0].text).toBe('Error: Run failed');
|
|
297
298
|
});
|
|
299
|
+
|
|
300
|
+
// SEC-1 regression: `gog --readonly drive mkdir x --readonly=false` reached
|
|
301
|
+
// Google on gog 0.41.0, defeating the GOG_READONLY kill switch.
|
|
302
|
+
it('refuses --readonly=false so GOG_READONLY cannot be overridden', async () => {
|
|
303
|
+
const harness = await setupHandlers();
|
|
304
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'mkdir', args: ['x', '--readonly=false'] });
|
|
305
|
+
expect(result.isError).toBe(true);
|
|
306
|
+
expect(result.content[0].text).toMatch(/--readonly=false is not allowed/);
|
|
307
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// SEC-3/SEC-4 through the escape hatch: GOG_FILE_ROOTS must bind gog_drive_run
|
|
312
|
+
// too, or `upload <gog's credentials.json>` exfiltrates what the roots protect.
|
|
313
|
+
describe('gog_drive_run path confinement', () => {
|
|
314
|
+
// The suite runs with GOG_FILE_ROOTS='/'; narrow it so confinement bites.
|
|
315
|
+
let prevRoots: string | undefined;
|
|
316
|
+
beforeEach(() => { prevRoots = process.env.GOG_FILE_ROOTS; process.env.GOG_FILE_ROOTS = '/nonexistent-gog-file-root'; });
|
|
317
|
+
afterEach(() => { process.env.GOG_FILE_ROOTS = prevRoots; });
|
|
318
|
+
|
|
319
|
+
it('refuses upload, whose local path is positional, pointing at gog_drive_upload', async () => {
|
|
320
|
+
const harness = await setupHandlers();
|
|
321
|
+
const result = await harness.callTool('gog_drive_run', {
|
|
322
|
+
subcommand: 'upload',
|
|
323
|
+
args: ['/Users/x/Library/Application Support/gogcli/credentials.json'],
|
|
324
|
+
});
|
|
325
|
+
expect(result.isError).toBe(true);
|
|
326
|
+
expect(result.content[0].text).toMatch(/gog_drive_upload/);
|
|
327
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('refuses a download --out outside GOG_FILE_ROOTS', async () => {
|
|
331
|
+
const harness = await setupHandlers();
|
|
332
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'download', args: ['f1', '--out=~/.zshrc'] });
|
|
333
|
+
expect(result.isError).toBe(true);
|
|
334
|
+
expect(result.content[0].text).toMatch(/--out "~\/.zshrc" is outside/);
|
|
335
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('refuses a --on-change hook, which runs a local command', async () => {
|
|
339
|
+
const harness = await setupHandlers();
|
|
340
|
+
const result = await harness.callTool('gog_drive_run', { subcommand: 'changes', args: ['poll', '--on-change=sh -c id'] });
|
|
341
|
+
expect(result.isError).toBe(true);
|
|
342
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('still forwards a --file Drive ID, which is not a local path', async () => {
|
|
346
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
347
|
+
const harness = await setupHandlers();
|
|
348
|
+
await harness.callTool('gog_drive_run', { subcommand: 'audit', args: ['sharing', '--file=1AbC'] });
|
|
349
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'audit', 'sharing', '--file=1AbC'], { account: undefined });
|
|
350
|
+
});
|
|
298
351
|
});
|
|
299
352
|
|
|
300
353
|
describe('gog_drive_extract_text', () => {
|
|
@@ -324,7 +377,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
324
377
|
['api', 'call', 'drive', 'v3', 'files.export', '--params={"fileId":"tmpDoc1","mimeType":"text/plain"}'],
|
|
325
378
|
{ account: undefined },
|
|
326
379
|
);
|
|
327
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc1', '--permanent', '--force'], { account: undefined });
|
|
380
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc1'), '--permanent', '--force'], { account: undefined });
|
|
328
381
|
});
|
|
329
382
|
|
|
330
383
|
it('passes an ocrLanguage hint on the copy', async () => {
|
|
@@ -340,7 +393,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
340
393
|
'--params={"fileId":"pdf1","ocrLanguage":"fr"}', '--body={"name":"gogcli-ocr-pdf1","mimeType":"application/vnd.google-apps.document"}'],
|
|
341
394
|
{ account: undefined, timeout: 300_000 },
|
|
342
395
|
);
|
|
343
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc2', '--permanent', '--force'], { account: undefined });
|
|
396
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc2'), '--permanent', '--force'], { account: undefined });
|
|
344
397
|
});
|
|
345
398
|
|
|
346
399
|
it('exports a native Google Doc directly with no convert or temp cleanup', async () => {
|
|
@@ -383,7 +436,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
383
436
|
const result = await harness.callTool('gog_drive_extract_text', { fileId: 'pdf1' });
|
|
384
437
|
expect(result.isError).toBe(true);
|
|
385
438
|
expect(result.content[0].text).toContain('export exploded');
|
|
386
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', 'tmpDoc3', '--permanent', '--force'], { account: undefined });
|
|
439
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'delete', pos('tmpDoc3'), '--permanent', '--force'], { account: undefined });
|
|
387
440
|
});
|
|
388
441
|
|
|
389
442
|
it('errors clearly when OCR conversion returns no id (and creates nothing to clean up)', async () => {
|
|
@@ -417,7 +470,7 @@ describe('gog_drive_extract_text', () => {
|
|
|
417
470
|
.mockResolvedValueOnce('body');
|
|
418
471
|
const harness = await setupHandlers();
|
|
419
472
|
await harness.callTool('gog_drive_extract_text', { fileId: 'doc1', account: 'me@x.com' });
|
|
420
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'doc1'], { account: 'me@x.com' });
|
|
473
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', pos('doc1')], { account: 'me@x.com' });
|
|
421
474
|
});
|
|
422
475
|
});
|
|
423
476
|
|
|
@@ -435,10 +488,44 @@ describe('gog_drive_read_bytes', () => {
|
|
|
435
488
|
expect(result.content[0].text).toContain('a.pdf');
|
|
436
489
|
expect(runner.runBinary).toHaveBeenCalledWith(
|
|
437
490
|
['api', 'call', 'drive', 'v3', 'files.get', '--params={"fileId":"f1","alt":"media"}'],
|
|
438
|
-
{ account: undefined },
|
|
491
|
+
{ account: undefined, maxOutputBytes: 8 * 1024 * 1024 },
|
|
439
492
|
);
|
|
440
493
|
});
|
|
441
494
|
|
|
495
|
+
// BUG-2: the size is known from `drive get` before a byte is fetched.
|
|
496
|
+
it('refuses a file over the inline ceiling without fetching its bytes', async () => {
|
|
497
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'backup.zip', mimeType: 'application/zip', size: '4294967296' } }));
|
|
498
|
+
const harness = await setupHandlers();
|
|
499
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
500
|
+
expect(result.isError).toBe(true);
|
|
501
|
+
expect(result.content[0].text).toMatch(/4294967296 bytes.*8 MiB/);
|
|
502
|
+
expect(result.content[0].text).toContain('gog_drive_extract_text');
|
|
503
|
+
expect(runner.runBinary).not.toHaveBeenCalled();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('names the file by id when an oversized file has no name', async () => {
|
|
507
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ size: 9 * 1024 * 1024 }));
|
|
508
|
+
const harness = await setupHandlers();
|
|
509
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f9' });
|
|
510
|
+
expect(result.content[0].text).toMatch(/^f9 is 9437184 bytes/);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('treats an unparseable size as unknown and relies on the fetch cap', async () => {
|
|
514
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', size: 'n/a' } }));
|
|
515
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
|
|
516
|
+
const harness = await setupHandlers();
|
|
517
|
+
const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
518
|
+
expect(result.isError).toBeFalsy();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('caps the fetch itself when Drive reports no size', async () => {
|
|
522
|
+
vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.bin', mimeType: 'application/octet-stream' } }));
|
|
523
|
+
vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
|
|
524
|
+
const harness = await setupHandlers();
|
|
525
|
+
await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
|
|
526
|
+
expect(runner.runBinary).toHaveBeenCalledWith(expect.any(Array), { account: undefined, maxOutputBytes: 8 * 1024 * 1024 });
|
|
527
|
+
});
|
|
528
|
+
|
|
442
529
|
it('names the text fallback when the resource type is one hosts refuse to render', async () => {
|
|
443
530
|
// The bytes arrive fine; some hosts just drop a non-image embedded resource
|
|
444
531
|
// ("Resources of type 'application/pdf' are not currently supported"), and
|