gogcli-mcp 4.3.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +647 -67
- package/dist/lib.js +654 -67
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/gmail-dispatch-guard.ts +36 -36
- package/src/lib.ts +9 -0
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +24 -3
- package/src/tools/calendar.ts +168 -7
- package/src/tools/chat.ts +78 -5
- package/src/tools/classroom.ts +83 -3
- package/src/tools/drive.ts +55 -3
- package/src/tools/gmail.ts +111 -20
- package/tests/gmail-dispatch-guard.test.ts +227 -14
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/calendar.test.ts +4 -1
- package/tests/tools/chat.test.ts +4 -1
- package/tests/tools/classroom.test.ts +4 -1
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/drive.test.ts +4 -1
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/run-vets.test.ts +131 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CONFIRM_TOKEN_TTL_DEFAULT_SECONDS,
|
|
4
|
+
confirmTokenTtlSeconds,
|
|
5
|
+
hashSendPayload,
|
|
6
|
+
issueConfirmToken,
|
|
7
|
+
resetConfirmTokenState,
|
|
8
|
+
sendConfirmFallbackEnabled,
|
|
9
|
+
verifyConfirmToken,
|
|
10
|
+
type ConfirmBinding,
|
|
11
|
+
} from '../src/send-confirm-token.js';
|
|
12
|
+
|
|
13
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
14
|
+
|
|
15
|
+
const BINDING: ConfirmBinding = {
|
|
16
|
+
tool: 'gog_gmail_drafts_send',
|
|
17
|
+
account: 'me@example.com',
|
|
18
|
+
target: 'r123',
|
|
19
|
+
revision: 'msg-1',
|
|
20
|
+
payloadHash: hashSendPayload({ to: 'a@example.com', body: 'hello' }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const NOW = 1_800_000_000_000;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env = { ...ORIGINAL_ENV };
|
|
27
|
+
delete process.env.GOG_SEND_CONFIRM_FALLBACK;
|
|
28
|
+
delete process.env.GOG_CONFIRM_SECRET;
|
|
29
|
+
delete process.env.GOG_CONFIRM_TTL_SECONDS;
|
|
30
|
+
resetConfirmTokenState();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
process.env = ORIGINAL_ENV;
|
|
35
|
+
resetConfirmTokenState();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('hashSendPayload', () => {
|
|
39
|
+
it('is independent of key order, so a re-read payload hashes the same', () => {
|
|
40
|
+
expect(hashSendPayload({ a: 1, b: { c: 2, d: [1, 2] } }))
|
|
41
|
+
.toBe(hashSendPayload({ b: { d: [1, 2], c: 2 }, a: 1 }));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('changes when any value changes, including array order', () => {
|
|
45
|
+
const base = hashSendPayload({ to: ['a', 'b'], body: 'x' });
|
|
46
|
+
expect(hashSendPayload({ to: ['b', 'a'], body: 'x' })).not.toBe(base);
|
|
47
|
+
expect(hashSendPayload({ to: ['a', 'b'], body: 'x ' })).not.toBe(base);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('treats an undefined field as absent', () => {
|
|
51
|
+
expect(hashSendPayload({ a: 1, b: undefined })).toBe(hashSendPayload({ a: 1 }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('distinguishes null from absent', () => {
|
|
55
|
+
expect(hashSendPayload({ a: null })).not.toBe(hashSendPayload({}));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('sendConfirmFallbackEnabled', () => {
|
|
60
|
+
it('is off unless GOG_SEND_CONFIRM_FALLBACK is exactly "token" (case-insensitive)', () => {
|
|
61
|
+
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
62
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = '1';
|
|
63
|
+
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
64
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = ' Token ';
|
|
65
|
+
expect(sendConfirmFallbackEnabled()).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('treats an unresolved .mcpb placeholder as unset', () => {
|
|
69
|
+
process.env.GOG_SEND_CONFIRM_FALLBACK = '${user_config.send_confirm_fallback}';
|
|
70
|
+
expect(sendConfirmFallbackEnabled()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('confirmTokenTtlSeconds', () => {
|
|
75
|
+
it('defaults to ten minutes', () => {
|
|
76
|
+
expect(CONFIRM_TOKEN_TTL_DEFAULT_SECONDS).toBe(600);
|
|
77
|
+
expect(confirmTokenTtlSeconds()).toBe(600);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('honours a positive integer GOG_CONFIRM_TTL_SECONDS', () => {
|
|
81
|
+
process.env.GOG_CONFIRM_TTL_SECONDS = '30';
|
|
82
|
+
expect(confirmTokenTtlSeconds()).toBe(30);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it.each(['0', '-5', 'abc', '1.5', ''])('falls back to the default for %j', (value) => {
|
|
86
|
+
process.env.GOG_CONFIRM_TTL_SECONDS = value;
|
|
87
|
+
expect(confirmTokenTtlSeconds()).toBe(600);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('issueConfirmToken / verifyConfirmToken', () => {
|
|
92
|
+
it('accepts a fresh token for the exact binding it was issued for', () => {
|
|
93
|
+
const { token, expiresAt } = issueConfirmToken(BINDING, NOW);
|
|
94
|
+
expect(expiresAt).toBe(new Date(NOW + 600_000).toISOString());
|
|
95
|
+
expect(verifyConfirmToken(token, BINDING, NOW + 1_000)).toEqual({ ok: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is single-use: the second presentation is TOKEN_REUSED', () => {
|
|
99
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
100
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
101
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_REUSED' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('expires after the TTL', () => {
|
|
105
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
106
|
+
expect(verifyConfirmToken(token, BINDING, NOW + 600_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('uses GOG_CONFIRM_TTL_SECONDS at issue time', () => {
|
|
110
|
+
process.env.GOG_CONFIRM_TTL_SECONDS = '5';
|
|
111
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
112
|
+
expect(verifyConfirmToken(token, BINDING, NOW + 5_001)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not consume a token that failed verification', () => {
|
|
116
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
117
|
+
const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'b@example.com' }) };
|
|
118
|
+
expect(verifyConfirmToken(token, changed, NOW)).toMatchObject({ ok: false, error: 'DRAFT_CHANGED' });
|
|
119
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reports DRAFT_CHANGED with reason payload-changed when the payload hash differs', () => {
|
|
123
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
124
|
+
const changed = { ...BINDING, payloadHash: hashSendPayload({ to: 'a@example.com', body: 'HELLO' }) };
|
|
125
|
+
expect(verifyConfirmToken(token, changed, NOW)).toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reports DRAFT_CHANGED with reason message-id-rotated when the revision rotated', () => {
|
|
129
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
130
|
+
expect(verifyConfirmToken(token, { ...BINDING, revision: 'msg-2' }, NOW))
|
|
131
|
+
.toEqual({ ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
['tool', { tool: 'gog_gmail_send' }],
|
|
136
|
+
['account', { account: 'other@example.com' }],
|
|
137
|
+
['draft/message', { target: 'r999' }],
|
|
138
|
+
] as const)('never accepts a token issued for a different %s', (_label, override) => {
|
|
139
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
140
|
+
expect(verifyConfirmToken(token, { ...BINDING, ...override }, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('rejects a tampered claim as TOKEN_INVALID', () => {
|
|
144
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
145
|
+
const [prefix, claims, sig] = token.split('.');
|
|
146
|
+
const decoded = JSON.parse(Buffer.from(claims!, 'base64url').toString('utf8'));
|
|
147
|
+
decoded.exp += 3_600_000;
|
|
148
|
+
const forged = `${prefix}.${Buffer.from(JSON.stringify(decoded)).toString('base64url')}.${sig}`;
|
|
149
|
+
expect(verifyConfirmToken(forged, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('rejects a tampered signature as TOKEN_INVALID', () => {
|
|
153
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
154
|
+
const flipped = token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA');
|
|
155
|
+
expect(verifyConfirmToken(flipped, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it.each(['', 'garbage', 'gct1.a.b', 'x.y.z', 'gct1..', 'gct1.a.b.c'])('rejects malformed token %j as TOKEN_INVALID', (token) => {
|
|
159
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('rejects a correctly signed token whose claims are not JSON', async () => {
|
|
163
|
+
const { createHmac } = await import('node:crypto');
|
|
164
|
+
process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
|
|
165
|
+
resetConfirmTokenState();
|
|
166
|
+
const claims = Buffer.from('not json').toString('base64url');
|
|
167
|
+
const sig = createHmac('sha256', 'a-shared-secret').update(`gct1.${claims}`).digest('base64url');
|
|
168
|
+
expect(verifyConfirmToken(`gct1.${claims}.${sig}`, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('rejects a token signed by a previous process (per-process secret)', () => {
|
|
172
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
173
|
+
resetConfirmTokenState();
|
|
174
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toMatchObject({ ok: false, error: 'TOKEN_INVALID' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('with GOG_CONFIRM_SECRET set, a token survives a restart', () => {
|
|
178
|
+
process.env.GOG_CONFIRM_SECRET = 'a-shared-secret';
|
|
179
|
+
resetConfirmTokenState();
|
|
180
|
+
const { token } = issueConfirmToken(BINDING, NOW);
|
|
181
|
+
resetConfirmTokenState();
|
|
182
|
+
expect(verifyConfirmToken(token, BINDING, NOW)).toEqual({ ok: true });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('binds a revision-less subject too', () => {
|
|
186
|
+
const noRevision = { ...BINDING, revision: undefined };
|
|
187
|
+
const { token } = issueConfirmToken(noRevision, NOW);
|
|
188
|
+
expect(verifyConfirmToken(token, noRevision, NOW)).toEqual({ ok: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('forgets used tokens once they would have expired anyway', () => {
|
|
192
|
+
const first = issueConfirmToken(BINDING, NOW);
|
|
193
|
+
expect(verifyConfirmToken(first.token, BINDING, NOW)).toEqual({ ok: true });
|
|
194
|
+
// A later verification prunes the spent entry; the old token is then
|
|
195
|
+
// rejected as expired rather than remembered forever.
|
|
196
|
+
const second = issueConfirmToken(BINDING, NOW + 700_000);
|
|
197
|
+
expect(verifyConfirmToken(second.token, BINDING, NOW + 700_000)).toEqual({ ok: true });
|
|
198
|
+
expect(verifyConfirmToken(first.token, BINDING, NOW + 700_000)).toMatchObject({ ok: false, error: 'TOKEN_EXPIRED' });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -7,7 +7,10 @@ import type { GogArg } from '../../src/runner.js';
|
|
|
7
7
|
|
|
8
8
|
vi.mock('../../src/runner.js');
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// The dispatch rail asks before guest-visible / outbound calls; this client accepts.
|
|
11
|
+
const setupHandlers = () => createTestHarness(registerCalendarTools, {
|
|
12
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
13
|
+
});
|
|
11
14
|
|
|
12
15
|
// gog_calendar_events answers in the compact rung by default, so every call
|
|
13
16
|
// carries the mask. These keep the window-flag tests below about window flags
|
package/tests/tools/chat.test.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { pos } from '../../src/argv.js';
|
|
|
6
6
|
|
|
7
7
|
vi.mock('../../src/runner.js');
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// The dispatch rail asks before guest-visible / outbound calls; this client accepts.
|
|
10
|
+
const setupHandlers = () => createTestHarness(registerChatTools, {
|
|
11
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
12
|
+
});
|
|
10
13
|
|
|
11
14
|
beforeEach(() => vi.clearAllMocks());
|
|
12
15
|
|
|
@@ -6,7 +6,10 @@ import { pos } from '../../src/argv.js';
|
|
|
6
6
|
|
|
7
7
|
vi.mock('../../src/runner.js');
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// The dispatch rail asks before guest-visible / outbound calls; this client accepts.
|
|
10
|
+
const setupHandlers = () => createTestHarness(registerClassroomTools, {
|
|
11
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
12
|
+
});
|
|
10
13
|
|
|
11
14
|
let harness: TestHarness;
|
|
12
15
|
|
|
@@ -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
|
+
});
|
|
@@ -6,7 +6,10 @@ import { pos } from '../../src/argv.js';
|
|
|
6
6
|
|
|
7
7
|
vi.mock('../../src/runner.js');
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// The dispatch rail asks before guest-visible / outbound calls; this client accepts.
|
|
10
|
+
const setupHandlers = () => createTestHarness(registerDriveTools, {
|
|
11
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
12
|
+
});
|
|
10
13
|
|
|
11
14
|
// gog_drive_ls answers in the compact rung by default, so every call carries
|
|
12
15
|
// the mask. These keep the flag-mapping tests below about flag mapping while
|