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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +714 -216
  4. package/dist/lib.js +750 -216
  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/file-roots.ts +66 -0
  14. package/src/gmail-dispatch-guard.ts +56 -16
  15. package/src/gmail-results.ts +21 -2
  16. package/src/lib.ts +6 -0
  17. package/src/run-path-guard.ts +118 -0
  18. package/src/runner.ts +86 -18
  19. package/src/tools/api.ts +39 -7
  20. package/src/tools/appscript.ts +12 -8
  21. package/src/tools/auth.ts +13 -3
  22. package/src/tools/calendar.ts +10 -8
  23. package/src/tools/chat.ts +16 -13
  24. package/src/tools/classroom.ts +27 -25
  25. package/src/tools/contacts.ts +5 -3
  26. package/src/tools/docs.ts +8 -6
  27. package/src/tools/drive.ts +42 -18
  28. package/src/tools/gmail.ts +71 -11
  29. package/src/tools/sheets.ts +10 -8
  30. package/src/tools/slides.ts +14 -9
  31. package/src/tools/tasks.ts +7 -5
  32. package/src/tools/utils.ts +52 -6
  33. package/tests/arg-guard.test.ts +80 -0
  34. package/tests/attachment-root.test.ts +130 -0
  35. package/tests/attachments.test.ts +8 -0
  36. package/tests/blob-upload.test.ts +4 -3
  37. package/tests/file-roots.test.ts +101 -0
  38. package/tests/gmail-dispatch-guard.test.ts +46 -0
  39. package/tests/gmail-results.test.ts +35 -1
  40. package/tests/run-path-guard.test.ts +142 -0
  41. package/tests/runner.test.ts +136 -0
  42. package/tests/tools/api.test.ts +74 -8
  43. package/tests/tools/appscript.test.ts +34 -8
  44. package/tests/tools/auth.test.ts +44 -17
  45. package/tests/tools/calendar.test.ts +29 -27
  46. package/tests/tools/chat.test.ts +46 -20
  47. package/tests/tools/classroom.test.ts +39 -38
  48. package/tests/tools/contacts.test.ts +3 -2
  49. package/tests/tools/docs.test.ts +44 -15
  50. package/tests/tools/drive.test.ts +106 -19
  51. package/tests/tools/gmail.test.ts +227 -29
  52. package/tests/tools/run-tool-examples.test.ts +69 -0
  53. package/tests/tools/sheets.test.ts +16 -15
  54. package/tests/tools/slides.test.ts +47 -11
  55. package/tests/tools/tasks.test.ts +7 -6
  56. package/tests/tools/utils.test.ts +32 -31
  57. package/vitest.config.ts +5 -0
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/server';
3
3
  import { registerSheetsTools } from '../../src/tools/sheets.js';
4
4
  import * as runner from '../../src/runner.js';
5
5
  import { createTestHarness } from '@chrischall/mcp-utils/test';
6
+ import { pos } from '../../src/argv.js';
6
7
 
7
8
  vi.mock('../../src/runner.js');
8
9
 
@@ -15,7 +16,7 @@ describe('gog_sheets_get', () => {
15
16
  vi.mocked(runner.run).mockResolvedValue('{"values":[["a","b"]]}');
16
17
  const harness = await setupHandlers();
17
18
  const result = await harness.callTool('gog_sheets_get', { spreadsheetId: 'sid', range: 'Sheet1!A1:B2' });
18
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'Sheet1!A1:B2'], { account: undefined });
19
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('Sheet1!A1:B2')], { account: undefined });
19
20
  expect(result.content[0].text).toBe('{"values":[["a","b"]]}');
20
21
  });
21
22
 
@@ -23,7 +24,7 @@ describe('gog_sheets_get', () => {
23
24
  vi.mocked(runner.run).mockResolvedValue('{}');
24
25
  const harness = await setupHandlers();
25
26
  await harness.callTool('gog_sheets_get', { spreadsheetId: 'sid', range: 'A1', account: 'other@gmail.com' });
26
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1'], { account: 'other@gmail.com' });
27
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('A1')], { account: 'other@gmail.com' });
27
28
  });
28
29
 
29
30
  it('appends auth list on failure when auth list succeeds', async () => {
@@ -59,7 +60,7 @@ describe('gog_sheets_update', () => {
59
60
  const values = [['hello', 'world']];
60
61
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1:B1', values });
61
62
  expect(runner.run).toHaveBeenCalledWith(
62
- ['sheets', 'update', 'sid', 'A1:B1', `--values-json=${JSON.stringify(values)}`],
63
+ ['sheets', 'update', pos('sid'), pos('A1:B1'), `--values-json=${JSON.stringify(values)}`],
63
64
  { account: undefined },
64
65
  );
65
66
  });
@@ -92,7 +93,7 @@ describe('gog_sheets_update', () => {
92
93
  const values = [['x']];
93
94
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1', values, dry_run: true });
94
95
  expect(runner.run).toHaveBeenCalledWith(
95
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
96
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`, '--dry-run'],
96
97
  { account: undefined },
97
98
  );
98
99
  });
@@ -110,7 +111,7 @@ describe('gog_sheets_update', () => {
110
111
  const values = [['=1+1']];
111
112
  await harness.callTool('gog_sheets_update', { spreadsheetId: 'sid', range: 'A1', values, fail_on_formula_error: true });
112
113
  expect(runner.run).toHaveBeenCalledWith(
113
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--fail-on-formula-error'],
114
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`, '--fail-on-formula-error'],
114
115
  { account: undefined },
115
116
  );
116
117
  });
@@ -125,7 +126,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
125
126
  });
126
127
  // Only the read happened — no update call.
127
128
  expect(runner.run).toHaveBeenCalledTimes(1);
128
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1:A1'], { account: undefined });
129
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', pos('sid'), pos('A1:A1')], { account: undefined });
129
130
  expect(result.content[0].text).toContain('Write aborted');
130
131
  expect(result.content[0].text).toContain('A1:A1');
131
132
  expect(result.content[0].text).toContain('1 cell');
@@ -142,7 +143,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
142
143
  });
143
144
  expect(runner.run).toHaveBeenCalledTimes(2);
144
145
  expect(vi.mocked(runner.run).mock.calls[1]![0]).toEqual(
145
- ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`],
146
+ ['sheets', 'update', pos('sid'), pos('A1'), `--values-json=${JSON.stringify(values)}`],
146
147
  );
147
148
  expect(result.content[0].text).toBe('{"updatedCells":1}');
148
149
  });
@@ -156,7 +157,7 @@ describe('gog_sheets_update fail_if_not_empty guard', () => {
156
157
  spreadsheetId: 'sid', range: 'Sheet1!A1',
157
158
  values: [['a', 'b'], ['c', 'd'], ['e', 'f']], fail_if_not_empty: true,
158
159
  });
159
- expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', 'sid', 'Sheet1!A1:B3']);
160
+ expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', pos('sid'), pos('Sheet1!A1:B3')]);
160
161
  });
161
162
 
162
163
  it('aborts without writing and diagnoses the error when the verification read fails', async () => {
@@ -234,7 +235,7 @@ describe('gog_sheets_append', () => {
234
235
  const values = [['r1c1', 'r1c2'], ['r2c1', 'r2c2']];
235
236
  await harness.callTool('gog_sheets_append', { spreadsheetId: 'sid', range: 'Sheet1!A:B', values });
236
237
  expect(runner.run).toHaveBeenCalledWith(
237
- ['sheets', 'append', 'sid', 'Sheet1!A:B', `--values-json=${JSON.stringify(values)}`],
238
+ ['sheets', 'append', pos('sid'), pos('Sheet1!A:B'), `--values-json=${JSON.stringify(values)}`],
238
239
  { account: undefined },
239
240
  );
240
241
  });
@@ -252,7 +253,7 @@ describe('gog_sheets_append', () => {
252
253
  const values = [['x']];
253
254
  await harness.callTool('gog_sheets_append', { spreadsheetId: 'sid', range: 'Sheet1!A:A', values, dry_run: true });
254
255
  expect(runner.run).toHaveBeenCalledWith(
255
- ['sheets', 'append', 'sid', 'Sheet1!A:A', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
256
+ ['sheets', 'append', pos('sid'), pos('Sheet1!A:A'), `--values-json=${JSON.stringify(values)}`, '--dry-run'],
256
257
  { account: undefined },
257
258
  );
258
259
  });
@@ -263,7 +264,7 @@ describe('gog_sheets_clear', () => {
263
264
  vi.mocked(runner.run).mockResolvedValue('{}');
264
265
  const harness = await setupHandlers();
265
266
  await harness.callTool('gog_sheets_clear', { spreadsheetId: 'sid', range: 'Sheet1!A1:Z100' });
266
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'clear', 'sid', 'Sheet1!A1:Z100'], { account: undefined });
267
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'clear', pos('sid'), pos('Sheet1!A1:Z100')], { account: undefined });
267
268
  });
268
269
 
269
270
  it('returns error text on failure', async () => {
@@ -278,7 +279,7 @@ describe('gog_sheets_clear', () => {
278
279
  const harness = await setupHandlers();
279
280
  await harness.callTool('gog_sheets_clear', { spreadsheetId: 'sid', range: 'Sheet1!A1:Z100', dry_run: true });
280
281
  expect(runner.run).toHaveBeenCalledWith(
281
- ['sheets', 'clear', 'sid', 'Sheet1!A1:Z100', '--dry-run'],
282
+ ['sheets', 'clear', pos('sid'), pos('Sheet1!A1:Z100'), '--dry-run'],
282
283
  { account: undefined },
283
284
  );
284
285
  });
@@ -300,7 +301,7 @@ describe('gog_sheets_metadata', () => {
300
301
  vi.mocked(runner.run).mockResolvedValue('{"title":"My Sheet","sheets":[{"title":"Sheet1"}]}');
301
302
  const harness = await setupHandlers();
302
303
  const result = await harness.callTool('gog_sheets_metadata', { spreadsheetId: 'sid' });
303
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata', 'sid'], { account: undefined });
304
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'metadata', pos('sid')], { account: undefined });
304
305
  expect(result.content[0].text).toContain('My Sheet');
305
306
  });
306
307
 
@@ -317,7 +318,7 @@ describe('gog_sheets_create', () => {
317
318
  vi.mocked(runner.run).mockResolvedValue('{"spreadsheetId":"newid","title":"Budget 2026"}');
318
319
  const harness = await setupHandlers();
319
320
  await harness.callTool('gog_sheets_create', { title: 'Budget 2026' });
320
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'create', 'Budget 2026'], { account: undefined });
321
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'create', pos('Budget 2026')], { account: undefined });
321
322
  });
322
323
 
323
324
  it('returns error text on failure', async () => {
@@ -333,7 +334,7 @@ describe('gog_sheets_find_replace', () => {
333
334
  vi.mocked(runner.run).mockResolvedValue('{"occurrencesChanged":3}');
334
335
  const harness = await setupHandlers();
335
336
  await harness.callTool('gog_sheets_find_replace', { spreadsheetId: 'sid', find: 'foo', replace: 'bar' });
336
- expect(runner.run).toHaveBeenCalledWith(['sheets', 'find-replace', 'sid', 'foo', 'bar'], { account: undefined });
337
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'find-replace', pos('sid'), pos('foo'), pos('bar')], { account: undefined });
337
338
  });
338
339
 
339
340
  it('returns error text on failure', async () => {
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerSlidesTools } from '../../src/tools/slides.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_slides_export', () => {
14
15
  vi.mocked(runner.run).mockResolvedValue('{}');
15
16
  const harness = await setupHandlers();
16
17
  await harness.callTool('gog_slides_export', { presentationId: 'p1' });
17
- expect(runner.run).toHaveBeenCalledWith(['slides', 'export', 'p1'], { account: undefined });
18
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'export', pos('p1')], { account: undefined });
18
19
  });
19
20
 
20
21
  it('passes --out and --format when provided', async () => {
@@ -22,7 +23,7 @@ describe('gog_slides_export', () => {
22
23
  const harness = await setupHandlers();
23
24
  await harness.callTool('gog_slides_export', { presentationId: 'p1', out: '/tmp/deck.pdf', format: 'pdf' });
24
25
  expect(runner.run).toHaveBeenCalledWith(
25
- ['slides', 'export', 'p1', '--out=/tmp/deck.pdf', '--format=pdf'],
26
+ ['slides', 'export', pos('p1'), '--out=/tmp/deck.pdf', '--format=pdf'],
26
27
  { account: undefined },
27
28
  );
28
29
  });
@@ -32,7 +33,7 @@ describe('gog_slides_export', () => {
32
33
  const harness = await setupHandlers();
33
34
  await harness.callTool('gog_slides_export', { presentationId: 'p1', out: '/tmp/deck.pptx', overwrite: true });
34
35
  expect(runner.run).toHaveBeenCalledWith(
35
- ['slides', 'export', 'p1', '--out=/tmp/deck.pptx', '--overwrite'],
36
+ ['slides', 'export', pos('p1'), '--out=/tmp/deck.pptx', '--overwrite'],
36
37
  { account: undefined },
37
38
  );
38
39
  });
@@ -50,7 +51,7 @@ describe('gog_slides_info', () => {
50
51
  vi.mocked(runner.run).mockResolvedValue('{}');
51
52
  const harness = await setupHandlers();
52
53
  await harness.callTool('gog_slides_info', { presentationId: 'p1' });
53
- expect(runner.run).toHaveBeenCalledWith(['slides', 'info', 'p1'], { account: undefined });
54
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'info', pos('p1')], { account: undefined });
54
55
  });
55
56
 
56
57
  it('returns error text on failure', async () => {
@@ -66,7 +67,7 @@ describe('gog_slides_create', () => {
66
67
  vi.mocked(runner.run).mockResolvedValue('{}');
67
68
  const harness = await setupHandlers();
68
69
  await harness.callTool('gog_slides_create', { title: 'Deck' });
69
- expect(runner.run).toHaveBeenCalledWith(['slides', 'create', 'Deck'], { account: undefined });
70
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'create', pos('Deck')], { account: undefined });
70
71
  });
71
72
 
72
73
  it('passes --parent and --template when provided', async () => {
@@ -74,7 +75,7 @@ describe('gog_slides_create', () => {
74
75
  const harness = await setupHandlers();
75
76
  await harness.callTool('gog_slides_create', { title: 'Deck', parent: 'folder1', template: 'tpl1' });
76
77
  expect(runner.run).toHaveBeenCalledWith(
77
- ['slides', 'create', 'Deck', '--parent=folder1', '--template=tpl1'],
78
+ ['slides', 'create', pos('Deck'), '--parent=folder1', '--template=tpl1'],
78
79
  { account: undefined },
79
80
  );
80
81
  });
@@ -93,7 +94,7 @@ describe('gog_slides_copy', () => {
93
94
  const harness = await setupHandlers();
94
95
  await harness.callTool('gog_slides_copy', { presentationId: 'p1', title: 'Copy' });
95
96
  expect(runner.run).toHaveBeenCalledWith(
96
- ['slides', 'copy', 'p1', 'Copy'],
97
+ ['slides', 'copy', pos('p1'), pos('Copy')],
97
98
  { account: undefined },
98
99
  );
99
100
  });
@@ -103,7 +104,7 @@ describe('gog_slides_copy', () => {
103
104
  const harness = await setupHandlers();
104
105
  await harness.callTool('gog_slides_copy', { presentationId: 'p1', title: 'Copy', parent: 'folder1' });
105
106
  expect(runner.run).toHaveBeenCalledWith(
106
- ['slides', 'copy', 'p1', 'Copy', '--parent=folder1'],
107
+ ['slides', 'copy', pos('p1'), pos('Copy'), '--parent=folder1'],
107
108
  { account: undefined },
108
109
  );
109
110
  });
@@ -121,7 +122,7 @@ describe('gog_slides_list_slides', () => {
121
122
  vi.mocked(runner.run).mockResolvedValue('{}');
122
123
  const harness = await setupHandlers();
123
124
  await harness.callTool('gog_slides_list_slides', { presentationId: 'p1' });
124
- expect(runner.run).toHaveBeenCalledWith(['slides', 'list-slides', 'p1'], { account: undefined });
125
+ expect(runner.run).toHaveBeenCalledWith(['slides', 'list-slides', pos('p1')], { account: undefined });
125
126
  });
126
127
 
127
128
  it('returns error text on failure', async () => {
@@ -138,7 +139,7 @@ describe('gog_slides_read_slide', () => {
138
139
  const harness = await setupHandlers();
139
140
  await harness.callTool('gog_slides_read_slide', { presentationId: 'p1', slideId: 's1' });
140
141
  expect(runner.run).toHaveBeenCalledWith(
141
- ['slides', 'read-slide', 'p1', 's1'],
142
+ ['slides', 'read-slide', pos('p1'), pos('s1')],
142
143
  { account: undefined },
143
144
  );
144
145
  });
@@ -148,7 +149,7 @@ describe('gog_slides_read_slide', () => {
148
149
  const harness = await setupHandlers();
149
150
  await harness.callTool('gog_slides_read_slide', { presentationId: 'p1', slideId: 's1', detail: true });
150
151
  expect(runner.run).toHaveBeenCalledWith(
151
- ['slides', 'read-slide', 'p1', 's1', '--detail'],
152
+ ['slides', 'read-slide', pos('p1'), pos('s1'), '--detail'],
152
153
  { account: undefined },
153
154
  );
154
155
  });
@@ -176,3 +177,38 @@ describe('gog_slides_run', () => {
176
177
  expect(result.content[0].text).toBe('Error: Run failed');
177
178
  });
178
179
  });
180
+
181
+ // SEC-3/SEC-4: every model-supplied server path must resolve inside an
182
+ // operator-configured root (GOG_FILE_ROOTS). The suite runs with '/' so the
183
+ // arg-shape tests can use any path; these narrow it.
184
+ async function withFileRoots<T>(roots: string, fn: () => Promise<T>): Promise<T> {
185
+ const prev = process.env.GOG_FILE_ROOTS;
186
+ process.env.GOG_FILE_ROOTS = roots;
187
+ try {
188
+ return await fn();
189
+ } finally {
190
+ process.env.GOG_FILE_ROOTS = prev;
191
+ }
192
+ }
193
+
194
+ describe('gog_slides_export — writes to disk', () => {
195
+ it('refuses an out path outside GOG_FILE_ROOTS', async () => {
196
+ const harness = await setupHandlers();
197
+ const result = await withFileRoots('/srv/gog-files', () => harness.callTool('gog_slides_export', {
198
+ presentationId: 'p1', out: '/Users/me/.zshrc', overwrite: true,
199
+ }));
200
+ expect(result.isError).toBe(true);
201
+ expect(result.content[0].text).toContain('out "/Users/me/.zshrc" is outside');
202
+ expect(runner.run).not.toHaveBeenCalled();
203
+ });
204
+
205
+ // SEC-4: clients auto-approve readOnlyHint tools; this one writes a file and
206
+ // can overwrite one, so the client must be allowed to ask.
207
+ it('is not advertised as read-only, and is marked destructive', async () => {
208
+ const harness = await setupHandlers();
209
+ const { tools } = await harness.client.listTools();
210
+ const tool = tools.find((t) => t.name === 'gog_slides_export')!;
211
+ expect(tool.annotations?.readOnlyHint).not.toBe(true);
212
+ expect(tool.annotations?.destructiveHint).toBe(true);
213
+ });
214
+ });
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerTasksTools } from '../../src/tools/tasks.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
 
@@ -30,7 +31,7 @@ describe('gog_tasks_list', () => {
30
31
  vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
31
32
  const harness = await setupHandlers();
32
33
  await harness.callTool('gog_tasks_list', { tasklistId: 'list1' });
33
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'list', 'list1'], { account: undefined });
34
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'list', pos('list1')], { account: undefined });
34
35
  });
35
36
 
36
37
  it('returns error text on failure', async () => {
@@ -46,7 +47,7 @@ describe('gog_tasks_get', () => {
46
47
  vi.mocked(runner.run).mockResolvedValue('{"id":"task1"}');
47
48
  const harness = await setupHandlers();
48
49
  await harness.callTool('gog_tasks_get', { tasklistId: 'list1', taskId: 'task1' });
49
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'get', 'list1', 'task1'], { account: undefined });
50
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'get', pos('list1'), pos('task1')], { account: undefined });
50
51
  });
51
52
 
52
53
  it('returns error text on failure', async () => {
@@ -63,7 +64,7 @@ describe('gog_tasks_add', () => {
63
64
  const harness = await setupHandlers();
64
65
  await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk' });
65
66
  expect(runner.run).toHaveBeenCalledWith(
66
- ['tasks', 'add', 'list1', '--title=Buy milk'],
67
+ ['tasks', 'add', pos('list1'), '--title=Buy milk'],
67
68
  { account: undefined },
68
69
  );
69
70
  });
@@ -73,7 +74,7 @@ describe('gog_tasks_add', () => {
73
74
  const harness = await setupHandlers();
74
75
  await harness.callTool('gog_tasks_add', { tasklistId: 'list1', title: 'Buy milk', notes: 'Whole milk', due: '2026-04-20' });
75
76
  expect(runner.run).toHaveBeenCalledWith(
76
- ['tasks', 'add', 'list1', '--title=Buy milk', '--notes=Whole milk', '--due=2026-04-20'],
77
+ ['tasks', 'add', pos('list1'), '--title=Buy milk', '--notes=Whole milk', '--due=2026-04-20'],
77
78
  { account: undefined },
78
79
  );
79
80
  });
@@ -91,7 +92,7 @@ describe('gog_tasks_done', () => {
91
92
  vi.mocked(runner.run).mockResolvedValue('{}');
92
93
  const harness = await setupHandlers();
93
94
  await harness.callTool('gog_tasks_done', { tasklistId: 'list1', taskId: 'task1' });
94
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'done', 'list1', 'task1'], { account: undefined });
95
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'done', pos('list1'), pos('task1')], { account: undefined });
95
96
  });
96
97
 
97
98
  it('returns error text on failure', async () => {
@@ -107,7 +108,7 @@ describe('gog_tasks_delete', () => {
107
108
  vi.mocked(runner.run).mockResolvedValue('{}');
108
109
  const harness = await setupHandlers();
109
110
  await harness.callTool('gog_tasks_delete', { tasklistId: 'list1', taskId: 'task1' });
110
- expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', 'list1', 'task1', '--force'], { account: undefined });
111
+ expect(runner.run).toHaveBeenCalledWith(['tasks', 'delete', pos('list1'), pos('task1'), '--force'], { account: undefined });
111
112
  });
112
113
 
113
114
  it('returns error text on failure', async () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as runner from '../../src/runner.js';
3
3
  import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
4
+ import { pos } from '../../src/argv.js';
4
5
 
5
6
  // PARTIAL mock: only `run` is stubbed; everything else stays real.
6
7
  vi.mock('../../src/runner.js', async (importOriginal) => ({
@@ -51,7 +52,7 @@ describe('formatAccountList', () => {
51
52
  email: 'chris.c.hall@gmail.com',
52
53
  subject: '109876543210987654321',
53
54
  client: 'default',
54
- services: ['gmail', 'drive'],
55
+ services: ['gmail', pos('drive')],
55
56
  scopes: ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/drive'],
56
57
  created_at: '2026-01-02T03:04:05Z',
57
58
  },
@@ -94,7 +95,7 @@ describe('formatAccountList', () => {
94
95
  describe('runOrDiagnose', () => {
95
96
  it('returns output text on success (not flagged as an error)', async () => {
96
97
  vi.mocked(runner.run).mockResolvedValue('{"ok":true}');
97
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
98
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
98
99
  expect(result.content[0].text).toBe('{"ok":true}');
99
100
  expect(result.isError).toBeUndefined();
100
101
  });
@@ -106,7 +107,7 @@ describe('runOrDiagnose', () => {
106
107
  it('leaves a lossless response byte-for-byte untouched', async () => {
107
108
  const raw = '{\n "id": "m1",\n "internalDate": "1785209760000"\n}';
108
109
  vi.mocked(runner.run).mockResolvedValue(raw);
109
- const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
110
+ const result = await runOrDiagnose(['gmail', 'raw', pos('m1')], { lossless: true });
110
111
  expect(result.content[0].text).toBe(raw);
111
112
  });
112
113
 
@@ -182,7 +183,7 @@ describe('runOrDiagnose', () => {
182
183
  it('does NOT minify a lossless response', async () => {
183
184
  const raw = '{\n "id": "m1"\n}';
184
185
  vi.mocked(runner.run).mockResolvedValue(raw);
185
- const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
186
+ const result = await runOrDiagnose(['gmail', 'raw', pos('m1')], { lossless: true });
186
187
  expect(result.content[0].text).toBe(raw);
187
188
  });
188
189
 
@@ -230,7 +231,7 @@ describe('runOrDiagnose', () => {
230
231
  vi.mocked(runner.run).mockResolvedValue(
231
232
  '{\n "id": "f1",\n "thumbnailLink": "https://lh3.googleusercontent.com/x=s220",\n "webViewLink": "https://docs.google.com/d/f1"\n}',
232
233
  );
233
- const result = await runOrDiagnose(['drive', 'get', 'f1'], { stripMedia: true });
234
+ const result = await runOrDiagnose(['drive', 'get', pos('f1')], { stripMedia: true });
234
235
  expect(result.content[0].text).toBe('{"id":"f1","webViewLink":"https://docs.google.com/d/f1"}');
235
236
  });
236
237
 
@@ -240,14 +241,14 @@ describe('runOrDiagnose', () => {
240
241
  vi.mocked(runner.run).mockResolvedValue(
241
242
  JSON.stringify({ files: [{ id: 'f1', hasThumbnail: false, thumbnailLink: 'https://x/y=s220', webViewLink: 'https://docs/f1' }] }),
242
243
  );
243
- const result = await runOrDiagnose(['drive', 'search', 'q'], { stripMedia: true });
244
+ const result = await runOrDiagnose(['drive', 'search', pos('q')], { stripMedia: true });
244
245
  const parsed = JSON.parse(result.content[0].text as string);
245
246
  expect(parsed.files[0]).toEqual({ id: 'f1', hasThumbnail: false, webViewLink: 'https://docs/f1' });
246
247
  });
247
248
 
248
249
  it('does not strip media unless asked', async () => {
249
250
  vi.mocked(runner.run).mockResolvedValue('{"thumbnailLink":"https://x/y=s220"}');
250
- const result = await runOrDiagnose(['drive', 'get', 'f1'], {});
251
+ const result = await runOrDiagnose(['drive', 'get', pos('f1')], {});
251
252
  expect(result.content[0].text).toBe('{"thumbnailLink":"https://x/y=s220"}');
252
253
  });
253
254
 
@@ -263,7 +264,7 @@ describe('runOrDiagnose', () => {
263
264
  vi.mocked(runner.run)
264
265
  .mockRejectedValueOnce(new Error('Doc not found'))
265
266
  .mockResolvedValueOnce('user@gmail.com');
266
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
267
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
267
268
  expect(result.content[0].text).toBe(
268
269
  'Error: Doc not found\n\nConfigured accounts:\nuser@gmail.com',
269
270
  );
@@ -284,7 +285,7 @@ describe('runOrDiagnose', () => {
284
285
  vi.mocked(runner.run)
285
286
  .mockRejectedValueOnce(new Error('refusing to delete gmail draft r123 without --force (non-interactive)'))
286
287
  .mockResolvedValueOnce(authListJson);
287
- const result = await runOrDiagnose(['gmail', 'drafts', 'delete', 'r123'], {});
288
+ const result = await runOrDiagnose(['gmail', 'drafts', 'delete', pos('r123')], {});
288
289
  const text = result.content[0].text;
289
290
  expect(text).toContain('Configured accounts:\nchris.c.hall@gmail.com');
290
291
  expect(text).not.toContain('scopes');
@@ -297,7 +298,7 @@ describe('runOrDiagnose', () => {
297
298
  vi.mocked(runner.run)
298
299
  .mockRejectedValueOnce(new Error('Doc not found'))
299
300
  .mockResolvedValueOnce('{"accounts":[]}');
300
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
301
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
301
302
  expect(result.content[0].text).toBe('Error: Doc not found\n\nConfigured accounts:\n(none)');
302
303
  });
303
304
 
@@ -305,7 +306,7 @@ describe('runOrDiagnose', () => {
305
306
  vi.mocked(runner.run)
306
307
  .mockRejectedValueOnce(new Error('Request failed with status 401'))
307
308
  .mockResolvedValueOnce('user@gmail.com');
308
- const result = await runOrDiagnose(['docs', 'comments', 'list', 'abc'], {});
309
+ const result = await runOrDiagnose(['docs', 'comments', 'list', pos('abc')], {});
309
310
  expect(result.content[0].text).toContain('Error: Request failed with status 401');
310
311
  expect(result.content[0].text).toContain('Configured accounts:\nuser@gmail.com');
311
312
  expect(result.content[0].text).toContain('gog_auth_add');
@@ -315,7 +316,7 @@ describe('runOrDiagnose', () => {
315
316
  vi.mocked(runner.run)
316
317
  .mockRejectedValueOnce(new Error('unauthorized access'))
317
318
  .mockResolvedValueOnce('user@gmail.com');
318
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
319
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
319
320
  expect(result.content[0].text).toContain('gog_auth_add');
320
321
  });
321
322
 
@@ -323,7 +324,7 @@ describe('runOrDiagnose', () => {
323
324
  vi.mocked(runner.run)
324
325
  .mockRejectedValueOnce(new Error('token has been expired or revoked'))
325
326
  .mockResolvedValueOnce('user@gmail.com');
326
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
327
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
327
328
  expect(result.content[0].text).toContain('gog_auth_add');
328
329
  });
329
330
 
@@ -331,7 +332,7 @@ describe('runOrDiagnose', () => {
331
332
  vi.mocked(runner.run)
332
333
  .mockRejectedValueOnce(new Error('invalid_grant'))
333
334
  .mockResolvedValueOnce('user@gmail.com');
334
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
335
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
335
336
  expect(result.content[0].text).toContain('gog_auth_add');
336
337
  });
337
338
 
@@ -346,7 +347,7 @@ describe('runOrDiagnose', () => {
346
347
  vi.mocked(runner.run)
347
348
  .mockRejectedValueOnce(new Error('429 rateLimitExceeded: the access token expired mid-request, retry'))
348
349
  .mockResolvedValueOnce('user@gmail.com');
349
- const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
350
+ const result = await runOrDiagnose(['sheets', 'get', pos('A1')], {});
350
351
  const text = result.content[0].text as string;
351
352
  expect(text).toContain('often transient');
352
353
  expect(text).not.toContain('gog_auth_add');
@@ -359,7 +360,7 @@ describe('runOrDiagnose', () => {
359
360
  vi.mocked(runner.run)
360
361
  .mockRejectedValueOnce(new Error('401 unauthorized (quota project unset)'))
361
362
  .mockResolvedValueOnce('user@gmail.com');
362
- const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
363
+ const result = await runOrDiagnose(['sheets', 'get', pos('A1')], {});
363
364
  expect(result.content[0].text).toContain('gog_auth_add');
364
365
  });
365
366
 
@@ -375,7 +376,7 @@ describe('runOrDiagnose', () => {
375
376
  new Error('page token accepted; the requested export link has expired and must be regenerated'),
376
377
  )
377
378
  .mockResolvedValueOnce('user@gmail.com');
378
- const result = await runOrDiagnose(['drive', 'export', 'abc'], {});
379
+ const result = await runOrDiagnose(['drive', pos('export'), pos('abc')], {});
379
380
  expect(result.content[0].text).not.toContain('gog_auth_add');
380
381
  });
381
382
 
@@ -383,7 +384,7 @@ describe('runOrDiagnose', () => {
383
384
  vi.mocked(runner.run)
384
385
  .mockRejectedValueOnce(new Error('oauth2: "invalid_grant" "Token has been expired or revoked."'))
385
386
  .mockResolvedValueOnce('user@gmail.com');
386
- const result = await runOrDiagnose(['gmail', 'search', 'is:unread'], {});
387
+ const result = await runOrDiagnose(['gmail', 'search', pos('is:unread')], {});
387
388
  const text = result.content[0].text as string;
388
389
  // plain-English cause
389
390
  expect(text).toContain('7-day');
@@ -398,7 +399,7 @@ describe('runOrDiagnose', () => {
398
399
  vi.mocked(runner.run)
399
400
  .mockRejectedValueOnce(new Error('Request failed with status 401'))
400
401
  .mockResolvedValueOnce('user@gmail.com');
401
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
402
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
402
403
  const text = result.content[0].text as string;
403
404
  expect(text).toContain('gog_auth_add');
404
405
  expect(text).not.toContain('In production');
@@ -408,7 +409,7 @@ describe('runOrDiagnose', () => {
408
409
  vi.mocked(runner.run)
409
410
  .mockRejectedValueOnce(new Error('Request failed with status 401'))
410
411
  .mockRejectedValueOnce(new Error('auth list failed'));
411
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
412
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
412
413
  expect(result.content[0].text).toContain('Error: Request failed with status 401');
413
414
  expect(result.content[0].text).toContain('gog_auth_add');
414
415
  expect(result.content[0].text).not.toContain('Configured accounts');
@@ -418,7 +419,7 @@ describe('runOrDiagnose', () => {
418
419
  vi.mocked(runner.run)
419
420
  .mockRejectedValueOnce(new Error('Doc not found'))
420
421
  .mockRejectedValueOnce(new Error('auth list failed'));
421
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
422
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
422
423
  expect(result.content[0].text).toBe('Error: Doc not found');
423
424
  expect(result.isError).toBe(true);
424
425
  expect(result.content[0].text).not.toContain('gog_auth_add');
@@ -428,7 +429,7 @@ describe('runOrDiagnose', () => {
428
429
  vi.mocked(runner.run)
429
430
  .mockRejectedValueOnce(new Error('Request failed with status 429'))
430
431
  .mockResolvedValueOnce('user@gmail.com');
431
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
432
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
432
433
  expect(result.content[0].text).toContain('transient');
433
434
  expect(result.content[0].text).toContain('Retry');
434
435
  });
@@ -439,7 +440,7 @@ describe('runOrDiagnose', () => {
439
440
  vi.mocked(runner.run)
440
441
  .mockRejectedValueOnce(new Error(`Request failed with status ${status}`))
441
442
  .mockResolvedValueOnce('user@gmail.com');
442
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
443
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
443
444
  expect(result.content[0].text, `status ${status}`).toContain('transient');
444
445
  }
445
446
  });
@@ -450,7 +451,7 @@ describe('runOrDiagnose', () => {
450
451
  vi.mocked(runner.run)
451
452
  .mockRejectedValueOnce(new Error(msg))
452
453
  .mockResolvedValueOnce('user@gmail.com');
453
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
454
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
454
455
  expect(result.content[0].text, msg).toContain('transient');
455
456
  }
456
457
  });
@@ -459,7 +460,7 @@ describe('runOrDiagnose', () => {
459
460
  vi.mocked(runner.run)
460
461
  .mockRejectedValueOnce(new Error('DEADLINE_EXCEEDED: context deadline exceeded'))
461
462
  .mockResolvedValueOnce('user@gmail.com');
462
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
463
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
463
464
  expect(result.content[0].text).toContain('transient');
464
465
  });
465
466
 
@@ -467,7 +468,7 @@ describe('runOrDiagnose', () => {
467
468
  vi.mocked(runner.run)
468
469
  .mockRejectedValueOnce(new Error('Request failed with status 404'))
469
470
  .mockResolvedValueOnce('user@gmail.com');
470
- const result = await runOrDiagnose(['sheets', 'get', 'abc', 'A1'], {});
471
+ const result = await runOrDiagnose(['sheets', 'get', pos('abc'), pos('A1')], {});
471
472
  expect(result.content[0].text).not.toContain('transient');
472
473
  });
473
474
 
@@ -475,7 +476,7 @@ describe('runOrDiagnose', () => {
475
476
  vi.mocked(runner.run)
476
477
  .mockRejectedValueOnce(new Error('Request failed with status 401'))
477
478
  .mockResolvedValueOnce('user@gmail.com');
478
- const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
479
+ const result = await runOrDiagnose(['docs', 'cat', pos('abc')], {});
479
480
  expect(result.content[0].text).toContain('gog_auth_add');
480
481
  expect(result.content[0].text).not.toContain('transient');
481
482
  });
@@ -484,7 +485,7 @@ describe('runOrDiagnose', () => {
484
485
  vi.mocked(runner.run)
485
486
  .mockRejectedValueOnce(new Error('Request failed with status 503'))
486
487
  .mockRejectedValueOnce(new Error('auth list failed'));
487
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
488
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
488
489
  expect(result.content[0].text).toContain('transient');
489
490
  expect(result.content[0].text).not.toContain('Configured accounts');
490
491
  });
@@ -493,7 +494,7 @@ describe('runOrDiagnose', () => {
493
494
  vi.mocked(runner.run)
494
495
  .mockRejectedValueOnce(new Error('Range (Sheet1!AP1:AW1) exceeds grid limits. Max rows: 1000, max columns: 41'))
495
496
  .mockResolvedValueOnce('user@gmail.com');
496
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'AP1'], {});
497
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('AP1')], {});
497
498
  expect(result.content[0].text).toContain('exceeds grid limits');
498
499
  expect(result.content[0].text).toContain('gog_sheets_insert');
499
500
  });
@@ -502,7 +503,7 @@ describe('runOrDiagnose', () => {
502
503
  vi.mocked(runner.run)
503
504
  .mockRejectedValueOnce(new Error('Spreadsheet not found'))
504
505
  .mockResolvedValueOnce('user@gmail.com');
505
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
506
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
506
507
  expect(result.content[0].text).not.toContain('gog_sheets_insert');
507
508
  });
508
509
 
@@ -510,7 +511,7 @@ describe('runOrDiagnose', () => {
510
511
  vi.mocked(runner.run)
511
512
  .mockRejectedValueOnce(new Error('exceeds grid limits. Max rows: 1000, max columns: 41'))
512
513
  .mockRejectedValueOnce(new Error('auth list failed'));
513
- const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
514
+ const result = await runOrDiagnose(['sheets', 'update', pos('abc'), pos('A1')], {});
514
515
  expect(result.content[0].text).toContain('gog_sheets_insert');
515
516
  expect(result.content[0].text).not.toContain('Configured accounts');
516
517
  });
package/vitest.config.ts CHANGED
@@ -20,6 +20,11 @@ export default defineConfig({
20
20
  GOG_ACCOUNT: '',
21
21
  GOG_PATH: '',
22
22
  GOG_READONLY: '',
23
+ // The arg-shape tests pass arbitrary server paths (/tmp/shot.png,
24
+ // /path/to/file); confinement is exercised explicitly by the tests that
25
+ // narrow GOG_FILE_ROOTS themselves (tests/file-roots.test.ts and the
26
+ // per-tool "refuses a path outside" cases).
27
+ GOG_FILE_ROOTS: '/',
23
28
  },
24
29
  coverage: {
25
30
  provider: 'v8',