gogcli-mcp 2.24.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -4
- package/SKILL.md +10 -5
- package/dist/index.js +525 -57
- package/dist/lib.js +457 -18
- package/manifest.json +82 -2
- package/mint.yaml +106 -0
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/gmail-results.ts +29 -2
- package/src/lib.ts +16 -0
- package/src/runner.ts +170 -15
- package/src/server.ts +6 -0
- package/src/tools/appscript.ts +173 -0
- package/src/tools/calendar.ts +56 -2
- package/src/tools/chat.ts +253 -0
- package/src/tools/gmail.ts +17 -3
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/gmail-results.test.ts +47 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/server.test.ts +2 -0
- package/tests/tools/appscript.test.ts +159 -0
- package/tests/tools/calendar.test.ts +121 -0
- package/tests/tools/chat.test.ts +284 -0
- package/tests/tools/gmail.test.ts +89 -0
|
@@ -8,11 +8,13 @@ import type { Spawner } from '../src/runner.js';
|
|
|
8
8
|
// its own file because the mock would defeat the byte-level round-trip
|
|
9
9
|
// assertions in runner-file-args.test.ts.
|
|
10
10
|
const mkdtemp = vi.fn(async () => '/tmp/gogcli-mcp-fake');
|
|
11
|
+
const mkdir = vi.fn(async () => undefined);
|
|
11
12
|
const writeFile = vi.fn(async () => {});
|
|
12
13
|
const rm = vi.fn(async () => {});
|
|
13
14
|
|
|
14
15
|
vi.mock('node:fs/promises', () => ({
|
|
15
16
|
mkdtemp: (...args: unknown[]) => mkdtemp(...(args as [])),
|
|
17
|
+
mkdir: (...args: unknown[]) => mkdir(...(args as [])),
|
|
16
18
|
writeFile: (...args: unknown[]) => writeFile(...(args as [])),
|
|
17
19
|
rm: (...args: unknown[]) => rm(...(args as [])),
|
|
18
20
|
}));
|
|
@@ -36,9 +38,11 @@ const fileArg = { kind: 'file', flag: 'body-file', contents: 'payload' } as cons
|
|
|
36
38
|
describe('temp-file materialization failures', () => {
|
|
37
39
|
beforeEach(() => {
|
|
38
40
|
mkdtemp.mockClear();
|
|
41
|
+
mkdir.mockClear();
|
|
39
42
|
writeFile.mockClear();
|
|
40
43
|
rm.mockClear();
|
|
41
44
|
rm.mockImplementation(async () => {});
|
|
45
|
+
mkdir.mockImplementation(async () => undefined);
|
|
42
46
|
writeFile.mockImplementation(async () => {});
|
|
43
47
|
});
|
|
44
48
|
|
|
@@ -83,12 +87,53 @@ describe('temp-file materialization failures', () => {
|
|
|
83
87
|
await expect(run(['gmail', 'send', fileArg], { spawner })).rejects.toThrow('gog: invalid draft');
|
|
84
88
|
});
|
|
85
89
|
|
|
86
|
-
it('writes the payload
|
|
90
|
+
it('writes the payload as owner-only utf8 bytes', async () => {
|
|
87
91
|
await run(['gmail', 'send', fileArg], { spawner: okSpawner() });
|
|
88
92
|
expect(writeFile).toHaveBeenCalledWith(
|
|
89
93
|
expect.stringContaining('body-file.txt'),
|
|
90
|
-
'payload',
|
|
91
|
-
{
|
|
94
|
+
Buffer.from('payload', 'utf8'),
|
|
95
|
+
{ mode: 0o600 },
|
|
92
96
|
);
|
|
97
|
+
// Each payload lands in its own numbered subdirectory of the temp dir, so a
|
|
98
|
+
// caller-chosen basename can never clobber another payload's.
|
|
99
|
+
expect(mkdir).toHaveBeenCalledWith('/tmp/gogcli-mcp-fake/0', { recursive: true, mode: 0o700 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('decodes a base64 payload to real bytes and honours a caller filename', async () => {
|
|
103
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
104
|
+
await run([
|
|
105
|
+
'gmail', 'send',
|
|
106
|
+
{ kind: 'file', flag: 'attach', contents: png.toString('base64'), encoding: 'base64', filename: 'Screenshot 2026-06-13 152500.png' },
|
|
107
|
+
], { spawner: okSpawner() });
|
|
108
|
+
expect(writeFile).toHaveBeenCalledWith(
|
|
109
|
+
'/tmp/gogcli-mcp-fake/0/Screenshot 2026-06-13 152500.png',
|
|
110
|
+
png,
|
|
111
|
+
{ mode: 0o600 },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('gives each file arg its own subdirectory so identical basenames survive', async () => {
|
|
116
|
+
const spawner = okSpawner();
|
|
117
|
+
await run([
|
|
118
|
+
'gmail', 'send',
|
|
119
|
+
{ kind: 'file', flag: 'attach', contents: 'YQ==', encoding: 'base64', filename: 'chart.png' },
|
|
120
|
+
{ kind: 'file', flag: 'attach', contents: 'Yg==', encoding: 'base64', filename: 'chart.png' },
|
|
121
|
+
], { spawner });
|
|
122
|
+
const written = writeFile.mock.calls.map((c) => c[0]);
|
|
123
|
+
expect(written).toEqual(['/tmp/gogcli-mcp-fake/0/chart.png', '/tmp/gogcli-mcp-fake/1/chart.png']);
|
|
124
|
+
// Both survive as distinct --attach values rather than one clobbering the other.
|
|
125
|
+
const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
|
|
126
|
+
expect(argv.filter((a) => a.startsWith('--attach='))).toHaveLength(2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('emits a positional file arg as a bare path, not --flag=path', async () => {
|
|
130
|
+
const spawner = okSpawner();
|
|
131
|
+
await run([
|
|
132
|
+
'drive', 'upload',
|
|
133
|
+
{ kind: 'file', flag: 'localPath', contents: 'YQ==', encoding: 'base64', filename: 'notes.md', positional: true },
|
|
134
|
+
], { spawner });
|
|
135
|
+
const argv = (spawner as unknown as { mock: { calls: [string, string[]][] } }).mock.calls[0][1];
|
|
136
|
+
expect(argv).toContain('/tmp/gogcli-mcp-fake/0/notes.md');
|
|
137
|
+
expect(argv.some((a) => a.startsWith('--localPath='))).toBe(false);
|
|
93
138
|
});
|
|
94
139
|
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -592,6 +592,132 @@ describe('run', () => {
|
|
|
592
592
|
expect(tokensOnly).not.toContain('[REDACTED]');
|
|
593
593
|
});
|
|
594
594
|
|
|
595
|
+
// ==========================================================================
|
|
596
|
+
// BASE64 PAYLOAD SURVIVAL — the "Invalid Base64 string" defect.
|
|
597
|
+
//
|
|
598
|
+
// `1//` is spelled entirely in the base64 alphabet, so the refresh-token
|
|
599
|
+
// pattern used to match inside attachment bytes and eat forward to the next
|
|
600
|
+
// `+` or `/`. A 72 KiB PNG is ~97k base64 chars, giving ~0.37 expected `1//`
|
|
601
|
+
// runs — so roughly a THIRD of all attachments came back corrupt, and the
|
|
602
|
+
// client rejected them with an MCP -32602 "Invalid Base64 string".
|
|
603
|
+
//
|
|
604
|
+
// It looked filename-dependent because it is content-dependent and therefore
|
|
605
|
+
// uncorrelated with anything visible. These tests pin the real variable.
|
|
606
|
+
// ==========================================================================
|
|
607
|
+
const isValidBase64 = (s: string): boolean => Buffer.from(s, 'base64').toString('base64') === s;
|
|
608
|
+
|
|
609
|
+
it('leaves a base64 payload containing the refresh-token spelling intact', async () => {
|
|
610
|
+
// `1//` sitting mid-blob, preceded by base64 characters — the exact shape
|
|
611
|
+
// that used to be deleted.
|
|
612
|
+
const payload = `AAAA1//abcdefghijklmnop${'QRSTuvwx'.repeat(4)}`;
|
|
613
|
+
expect(payload).toContain('1//');
|
|
614
|
+
const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
|
|
615
|
+
const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
|
|
616
|
+
expect(JSON.parse(out).contentBase64).toBe(payload);
|
|
617
|
+
expect(out).not.toContain('[REDACTED]');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('keeps every generated attachment payload valid base64 (the ~30% failure)', async () => {
|
|
621
|
+
// 200 payloads at the reported sizes. Before the fix ~30% of these came back
|
|
622
|
+
// undecodable; the assertion is that ALL of them survive, not most.
|
|
623
|
+
for (let i = 0; i < 200; i += 1) {
|
|
624
|
+
const bytes = Buffer.alloc(4096);
|
|
625
|
+
// Deterministic filler that still produces `1//` runs at the natural rate:
|
|
626
|
+
// a counter-driven byte pattern, seeded differently per iteration.
|
|
627
|
+
for (let b = 0; b < bytes.length; b += 1) bytes[b] = (b * 31 + i * 7) % 256;
|
|
628
|
+
const payload = bytes.toString('base64');
|
|
629
|
+
const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
|
|
630
|
+
const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
|
|
631
|
+
const got = JSON.parse(out).contentBase64 as string;
|
|
632
|
+
expect(isValidBase64(got)).toBe(true);
|
|
633
|
+
expect(got).toBe(payload);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// The left-boundary anchor must not narrow detection. Every character NOT in
|
|
638
|
+
// the standard base64 alphabet is a delimiter a real token turns up after, and
|
|
639
|
+
// `=` is the one that matters most: the form-encoded spelling is not covered by
|
|
640
|
+
// the shared redactor's query-param rule, which requires a preceding `?`/`&`.
|
|
641
|
+
it.each([
|
|
642
|
+
['refresh_token=1//0eFORM-ENCODED-LEAK', '1//0eFORM-ENCODED-LEAK'],
|
|
643
|
+
['access_token=ya29.a0FORM-ENCODED-LEAK', 'ya29.a0FORM-ENCODED-LEAK'],
|
|
644
|
+
['grant:1//0eCOLON-LEAK', '1//0eCOLON-LEAK'],
|
|
645
|
+
['[1//0eBRACKET-LEAK]', '1//0eBRACKET-LEAK'],
|
|
646
|
+
['token is ya29.a0SPACE-LEAK', 'ya29.a0SPACE-LEAK'],
|
|
647
|
+
])('redacts a token delimited by %j', async (stdout, secret) => {
|
|
648
|
+
const spawner = makeSpawner(0, stdout, '');
|
|
649
|
+
const out = await run(['auth', 'list'], { spawner });
|
|
650
|
+
expect(out).not.toContain(secret);
|
|
651
|
+
expect(out).toContain('[REDACTED]');
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("redactMode 'tokens' also catches the form-encoded spelling", async () => {
|
|
655
|
+
// This path runs ONLY redactGoogleTokens, so the anchor is the whole defence.
|
|
656
|
+
const spawner = makeSpawner(0, 'refresh_token=1//0eTOKENS-MODE-LEAK', '');
|
|
657
|
+
const out = await run(['auth', 'add'], { spawner, redactMode: 'tokens' });
|
|
658
|
+
expect(out).not.toContain('1//0eTOKENS-MODE-LEAK');
|
|
659
|
+
expect(out).toContain('[REDACTED]');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('still redacts a real refresh token, which is never welded to base64', async () => {
|
|
663
|
+
// The anchor must not have bought base64 survival at the cost of detection:
|
|
664
|
+
// a genuine token is always delimited (quote, space, `=`, `:`), so it still
|
|
665
|
+
// matches.
|
|
666
|
+
const spawner = makeSpawner(0, '{"refresh_token":"1//0eREAL-REFRESH-TOKEN"}', '');
|
|
667
|
+
const out = await run(['auth', 'list'], { spawner });
|
|
668
|
+
expect(out).not.toContain('1//0eREAL-REFRESH-TOKEN');
|
|
669
|
+
expect(out).toContain('[REDACTED]');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('opaqueFields exempts a named blob from redaction it would otherwise fail', async () => {
|
|
673
|
+
// A payload that spells a Google API key by chance — still possible after
|
|
674
|
+
// the boundary anchor, because AIza… needs no delimiter. The field
|
|
675
|
+
// exemption is what covers this class rather than one pattern.
|
|
676
|
+
// `/` supplies the word boundary AIza… needs, and every character here is
|
|
677
|
+
// in the base64 alphabet — so this is a payload a real file can produce.
|
|
678
|
+
const payload = `AAAA/AIza${'B'.repeat(35)}/CCC`;
|
|
679
|
+
expect(Buffer.from(payload, 'base64').toString('base64')).toBe(payload); // genuinely valid base64
|
|
680
|
+
const spawner = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
|
|
681
|
+
const bare = await run(['gmail', 'attachment', 'm1', '0'], { spawner });
|
|
682
|
+
expect(bare).toContain('[REDACTED]'); // unprotected, the shared redactor hits it
|
|
683
|
+
|
|
684
|
+
const spawner2 = makeSpawner(0, JSON.stringify({ contentBase64: payload }), '');
|
|
685
|
+
const guarded = await run(['gmail', 'attachment', 'm1', '0'], {
|
|
686
|
+
spawner: spawner2,
|
|
687
|
+
opaqueFields: ['contentBase64'],
|
|
688
|
+
});
|
|
689
|
+
expect(JSON.parse(guarded).contentBase64).toBe(payload);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('opaqueFields still redacts prose OUTSIDE the exempt field', async () => {
|
|
693
|
+
// The exemption is per-field, not per-response: a token in a sibling field
|
|
694
|
+
// must still be stripped.
|
|
695
|
+
const stdout = JSON.stringify({
|
|
696
|
+
contentBase64: 'QUJDREVGR0hJSktMTU5PUFFSU1Q=',
|
|
697
|
+
note: 'refreshed with 1//0eLEAK-IN-PROSE',
|
|
698
|
+
});
|
|
699
|
+
const spawner = makeSpawner(0, stdout, '');
|
|
700
|
+
const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
|
|
701
|
+
expect(out).not.toContain('1//0eLEAK-IN-PROSE');
|
|
702
|
+
expect(JSON.parse(out).contentBase64).toBe('QUJDREVGR0hJSktMTU5PUFFSU1Q=');
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('opaqueFields does not exempt a field carrying prose rather than a blob', async () => {
|
|
706
|
+
// Only an all-base64 value qualifies, so naming a field cannot be used to
|
|
707
|
+
// smuggle a credential through in a sentence.
|
|
708
|
+
const stdout = JSON.stringify({ contentBase64: 'token is 1//0eSMUGGLED-TOKEN here' });
|
|
709
|
+
const spawner = makeSpawner(0, stdout, '');
|
|
710
|
+
const out = await run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] });
|
|
711
|
+
expect(out).not.toContain('1//0eSMUGGLED-TOKEN');
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('opaqueFields never exempts anything on the ERROR path', async () => {
|
|
715
|
+
const spawner = makeSpawner(1, '', 'failed for 1//0eERROR-PATH-LEAK');
|
|
716
|
+
await expect(
|
|
717
|
+
run(['gmail', 'attachment', 'm1', '0'], { spawner, opaqueFields: ['contentBase64'] }),
|
|
718
|
+
).rejects.toThrow(/\[REDACTED\]/);
|
|
719
|
+
});
|
|
720
|
+
|
|
595
721
|
it("redactMode 'tokens' still strips real Google tokens", async () => {
|
|
596
722
|
const leak = 'url with token ya29.a0Ad52N3-STEP-LEAK and refresh 1//0eSTEP-REFRESH-LEAK';
|
|
597
723
|
const spawner = makeSpawner(0, leak, '');
|
package/tests/server.test.ts
CHANGED
|
@@ -14,8 +14,10 @@ describe('BASE_TOOL_REGISTRARS', () => {
|
|
|
14
14
|
// One representative tool per service registrar, in registrar order.
|
|
15
15
|
for (const expected of [
|
|
16
16
|
'gog_api_list',
|
|
17
|
+
'gog_appscript_get',
|
|
17
18
|
'gog_auth_list',
|
|
18
19
|
'gog_calendar_events',
|
|
20
|
+
'gog_chat_spaces_list',
|
|
19
21
|
'gog_classroom_courses_list',
|
|
20
22
|
'gog_contacts_list',
|
|
21
23
|
'gog_docs_cat',
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { registerAppScriptTools } from '../../src/tools/appscript.js';
|
|
3
|
+
import * as runner from '../../src/runner.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../src/runner.js');
|
|
7
|
+
|
|
8
|
+
const setupHandlers = () => createTestHarness(registerAppScriptTools);
|
|
9
|
+
|
|
10
|
+
beforeEach(() => vi.clearAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('gog_appscript_get', () => {
|
|
13
|
+
it('gets project metadata', async () => {
|
|
14
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
15
|
+
const harness = await setupHandlers();
|
|
16
|
+
await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
|
|
17
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns error text on failure', async () => {
|
|
21
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Get failed'));
|
|
22
|
+
const harness = await setupHandlers();
|
|
23
|
+
const result = await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
|
|
24
|
+
expect(result.content[0].text).toBe('Error: Get failed');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('gog_appscript_content', () => {
|
|
29
|
+
it('reads the project source', async () => {
|
|
30
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
31
|
+
const harness = await setupHandlers();
|
|
32
|
+
await harness.callTool('gog_appscript_content', { scriptId: 'S1', account: 'me@x.com' });
|
|
33
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'content', 'S1'], { account: 'me@x.com' });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('gog_appscript_pull', () => {
|
|
38
|
+
it('pulls into a directory', async () => {
|
|
39
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
40
|
+
const harness = await setupHandlers();
|
|
41
|
+
await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj' });
|
|
42
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'pull', 'S1', '/tmp/proj'], { account: undefined });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('passes --overwrite', async () => {
|
|
46
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
47
|
+
const harness = await setupHandlers();
|
|
48
|
+
await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj', overwrite: true });
|
|
49
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
50
|
+
['appscript', 'pull', 'S1', '/tmp/proj', '--overwrite'],
|
|
51
|
+
{ account: undefined },
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The directory resolves where gog runs, which is a different machine on the
|
|
56
|
+
// hosted connector. A caller who does not know that gets a "success" whose
|
|
57
|
+
// files they cannot reach.
|
|
58
|
+
it('says in its description where the directory resolves', async () => {
|
|
59
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
60
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
61
|
+
const configs = new Map<string, { description?: string }>();
|
|
62
|
+
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
63
|
+
configs.set(name, config as { description?: string });
|
|
64
|
+
return undefined as never;
|
|
65
|
+
});
|
|
66
|
+
registerAppScriptTools(server);
|
|
67
|
+
const desc = configs.get('gog_appscript_pull')?.description ?? '';
|
|
68
|
+
expect(desc).toContain('RESOLVED WHERE GOG RUNS');
|
|
69
|
+
expect(desc).toMatch(/gog_appscript_content/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('gog_appscript_create', () => {
|
|
74
|
+
it('creates a standalone project', async () => {
|
|
75
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
76
|
+
const harness = await setupHandlers();
|
|
77
|
+
await harness.callTool('gog_appscript_create', { title: 'Helpers' });
|
|
78
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'create', '--title=Helpers'], { account: undefined });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('binds the project to a Drive file', async () => {
|
|
82
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
83
|
+
const harness = await setupHandlers();
|
|
84
|
+
await harness.callTool('gog_appscript_create', { title: 'Bound', parentId: 'FILE1' });
|
|
85
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
86
|
+
['appscript', 'create', '--title=Bound', '--parent-id=FILE1'],
|
|
87
|
+
{ account: undefined },
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('gog_appscript_deployments', () => {
|
|
93
|
+
it('lists deployments with pagination', async () => {
|
|
94
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
95
|
+
const harness = await setupHandlers();
|
|
96
|
+
await harness.callTool('gog_appscript_deployments', { scriptId: 'S1', max: 10, pageToken: 'tok' });
|
|
97
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
98
|
+
['appscript', 'deployments', 'S1', '--max=10', '--page=tok'],
|
|
99
|
+
{ account: undefined },
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('gog_appscript_versions', () => {
|
|
105
|
+
it('lists versions', async () => {
|
|
106
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
107
|
+
const harness = await setupHandlers();
|
|
108
|
+
await harness.callTool('gog_appscript_versions', { scriptId: 'S1', all: true });
|
|
109
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'versions', 'S1', '--all'], { account: undefined });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('gog_appscript_run_function', () => {
|
|
114
|
+
it('runs a deployed function', async () => {
|
|
115
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
116
|
+
const harness = await setupHandlers();
|
|
117
|
+
await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
|
|
118
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', 'S1', 'doWork'], { account: undefined });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes params and --dev-mode', async () => {
|
|
122
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
123
|
+
const harness = await setupHandlers();
|
|
124
|
+
await harness.callTool('gog_appscript_run_function', {
|
|
125
|
+
scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
|
|
126
|
+
});
|
|
127
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
128
|
+
['appscript', 'run', 'S1', 'doWork', '--params=["a",1]', '--dev-mode'],
|
|
129
|
+
{ account: undefined },
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects params that are not a JSON array before spawning gog', async () => {
|
|
134
|
+
const harness = await setupHandlers();
|
|
135
|
+
const result = await harness.callTool('gog_appscript_run_function', {
|
|
136
|
+
scriptId: 'S1', functionName: 'doWork', params: '{"a":1}',
|
|
137
|
+
});
|
|
138
|
+
expect(result.isError).toBe(true);
|
|
139
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects params that are not JSON at all', async () => {
|
|
143
|
+
const harness = await setupHandlers();
|
|
144
|
+
const result = await harness.callTool('gog_appscript_run_function', {
|
|
145
|
+
scriptId: 'S1', functionName: 'doWork', params: 'a,1',
|
|
146
|
+
});
|
|
147
|
+
expect(result.isError).toBe(true);
|
|
148
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('gog_appscript_run', () => {
|
|
153
|
+
it('passes the subcommand and args through', async () => {
|
|
154
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
155
|
+
const harness = await setupHandlers();
|
|
156
|
+
await harness.callTool('gog_appscript_run', { subcommand: 'get', args: ['S1'] });
|
|
157
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -457,3 +457,124 @@ describe('gog_calendar_events — pagination (previously absent entirely)', () =
|
|
|
457
457
|
expect(out).not.toHaveProperty('nextPageToken');
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
// Reminders (gog >= 0.38.0 for --no-reminders, openclaw/gogcli#1002/#1016).
|
|
462
|
+
// Three distinct states share these two params and gog spells each one
|
|
463
|
+
// differently, so each is pinned separately: custom overrides, "no reminders at
|
|
464
|
+
// all", and — on update only — "go back to whatever the calendar says", which
|
|
465
|
+
// is an EMPTY --reminder rather than a flag of its own. All three were verified
|
|
466
|
+
// against a real gog 0.38.0 with --dry-run.
|
|
467
|
+
describe('gog_calendar_create reminders', () => {
|
|
468
|
+
it('passes each reminder as its own repeated flag', async () => {
|
|
469
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
470
|
+
const harness = await setupHandlers();
|
|
471
|
+
await harness.callTool('gog_calendar_create', {
|
|
472
|
+
calendarId: 'primary',
|
|
473
|
+
summary: 'Standup',
|
|
474
|
+
from: '2026-04-14T09:00:00Z',
|
|
475
|
+
to: '2026-04-14T09:30:00Z',
|
|
476
|
+
reminders: ['popup:30m', 'email:1d'],
|
|
477
|
+
});
|
|
478
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
479
|
+
['calendar', 'create', 'primary', '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
|
|
480
|
+
'--reminder=popup:30m', '--reminder=email:1d'],
|
|
481
|
+
{ account: undefined },
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('passes --no-reminders', async () => {
|
|
486
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
487
|
+
const harness = await setupHandlers();
|
|
488
|
+
await harness.callTool('gog_calendar_create', {
|
|
489
|
+
calendarId: 'primary',
|
|
490
|
+
summary: 'Quiet',
|
|
491
|
+
from: '2026-04-14T09:00:00Z',
|
|
492
|
+
to: '2026-04-14T09:30:00Z',
|
|
493
|
+
noReminders: true,
|
|
494
|
+
});
|
|
495
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
496
|
+
['calendar', 'create', 'primary', '--summary=Quiet', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
|
|
497
|
+
'--no-reminders'],
|
|
498
|
+
{ account: undefined },
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('rejects reminders and noReminders together, as gog does', async () => {
|
|
503
|
+
const harness = await setupHandlers();
|
|
504
|
+
const result = await harness.callTool('gog_calendar_create', {
|
|
505
|
+
calendarId: 'primary',
|
|
506
|
+
summary: 'Both',
|
|
507
|
+
from: '2026-04-14T09:00:00Z',
|
|
508
|
+
to: '2026-04-14T09:30:00Z',
|
|
509
|
+
reminders: ['popup:10m'],
|
|
510
|
+
noReminders: true,
|
|
511
|
+
});
|
|
512
|
+
expect(result.isError).toBe(true);
|
|
513
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('rejects more than the five reminders Google allows', async () => {
|
|
517
|
+
const harness = await setupHandlers();
|
|
518
|
+
const result = await harness.callTool('gog_calendar_create', {
|
|
519
|
+
calendarId: 'primary',
|
|
520
|
+
summary: 'Too many',
|
|
521
|
+
from: '2026-04-14T09:00:00Z',
|
|
522
|
+
to: '2026-04-14T09:30:00Z',
|
|
523
|
+
reminders: ['popup:1m', 'popup:2m', 'popup:3m', 'popup:4m', 'popup:5m', 'popup:6m'],
|
|
524
|
+
});
|
|
525
|
+
expect(result.isError).toBe(true);
|
|
526
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe('gog_calendar_update reminders', () => {
|
|
531
|
+
it('replaces the overrides', async () => {
|
|
532
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
533
|
+
const harness = await setupHandlers();
|
|
534
|
+
await harness.callTool('gog_calendar_update', {
|
|
535
|
+
calendarId: 'primary', eventId: 'evt1', reminders: ['popup:15m'],
|
|
536
|
+
});
|
|
537
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
538
|
+
['calendar', 'update', 'primary', 'evt1', '--reminder=popup:15m'],
|
|
539
|
+
{ account: undefined },
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Verified live: `calendar update … --reminder= --dry-run` patches
|
|
544
|
+
// reminders.useDefault=true with overrides cleared.
|
|
545
|
+
it('restores the calendar defaults with an empty reminder list', async () => {
|
|
546
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
547
|
+
const harness = await setupHandlers();
|
|
548
|
+
await harness.callTool('gog_calendar_update', {
|
|
549
|
+
calendarId: 'primary', eventId: 'evt1', reminders: [],
|
|
550
|
+
});
|
|
551
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
552
|
+
['calendar', 'update', 'primary', 'evt1', '--reminder='],
|
|
553
|
+
{ account: undefined },
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('turns every reminder off', async () => {
|
|
558
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
559
|
+
const harness = await setupHandlers();
|
|
560
|
+
await harness.callTool('gog_calendar_update', {
|
|
561
|
+
calendarId: 'primary', eventId: 'evt1', noReminders: true,
|
|
562
|
+
});
|
|
563
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
564
|
+
['calendar', 'update', 'primary', 'evt1', '--no-reminders'],
|
|
565
|
+
{ account: undefined },
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('leaves reminders alone when neither param is given', async () => {
|
|
570
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
571
|
+
const harness = await setupHandlers();
|
|
572
|
+
await harness.callTool('gog_calendar_update', {
|
|
573
|
+
calendarId: 'primary', eventId: 'evt1', summary: 'Renamed',
|
|
574
|
+
});
|
|
575
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
576
|
+
['calendar', 'update', 'primary', 'evt1', '--summary=Renamed'],
|
|
577
|
+
{ account: undefined },
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
});
|