gogcli-mcp 2.8.0 → 2.18.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 +20218 -19700
- package/dist/lib.js +15845 -23331
- package/manifest.json +33 -1
- package/package.json +7 -6
- package/server.json +2 -2
- package/src/connector-auth.ts +46 -0
- package/src/connector-runtime.ts +177 -0
- package/src/index.ts +7 -5
- package/src/lib.ts +8 -7
- package/src/runner.ts +225 -30
- package/src/server.ts +21 -25
- package/src/tools/api.ts +65 -0
- package/src/tools/auth.ts +112 -15
- package/src/tools/calendar.ts +24 -9
- package/src/tools/docs.ts +28 -3
- package/src/tools/drive.ts +124 -1
- package/src/tools/gmail.ts +7 -3
- package/src/tools/sheets.ts +6 -3
- package/src/tools/slides.ts +9 -4
- package/src/tools/tasks.ts +3 -1
- package/src/tools/utils.ts +163 -27
- package/src/worker.ts +99 -0
- package/tests/connector-auth.test.ts +28 -0
- package/tests/connector-runtime.test.ts +474 -0
- package/tests/runner-file-args-failure.test.ts +94 -0
- package/tests/runner-file-args.test.ts +232 -0
- package/tests/runner.test.ts +221 -13
- package/tests/server.test.ts +28 -28
- package/tests/tools/api.test.ts +107 -0
- package/tests/tools/auth.test.ts +187 -31
- package/tests/tools/calendar.test.ts +115 -52
- package/tests/tools/classroom.test.ts +77 -77
- package/tests/tools/contacts.test.ts +24 -24
- package/tests/tools/docs.test.ts +84 -46
- package/tests/tools/drive.test.ts +226 -55
- package/tests/tools/gmail.test.ts +61 -28
- package/tests/tools/sheets.test.ts +81 -70
- package/tests/tools/slides.test.ts +56 -36
- package/tests/tools/tasks.test.ts +33 -33
- package/tests/tools/utils.test.ts +116 -2
- package/tests/worker.test.ts +142 -0
- package/tsconfig.json +4 -1
- package/vitest.config.ts +33 -2
- package/tests/helpers/test-harness.ts +0 -27
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { run } from '../src/runner.js';
|
|
3
|
+
import type { GogArg, GogExecutor, GogFileArg } from '../src/runner.js';
|
|
4
|
+
import { makeFlyExecutor, wrapServer } from '../src/connector-runtime.js';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllEnvs();
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('wrapServer', () => {
|
|
13
|
+
// Neutralize ambient GOG_ACCOUNT / GOG_READONLY so run()'s assembled arg list
|
|
14
|
+
// is deterministic regardless of the shell the suite runs in.
|
|
15
|
+
function stubEnv() {
|
|
16
|
+
vi.stubEnv('GOG_ACCOUNT', '');
|
|
17
|
+
vi.stubEnv('GOG_READONLY', '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
it('scopes each registerTool handler so run() forwards to the injected executor', async () => {
|
|
21
|
+
stubEnv();
|
|
22
|
+
let captured: ((...a: unknown[]) => unknown) | undefined;
|
|
23
|
+
const server = {
|
|
24
|
+
registerTool(_name: string, _config: unknown, handler: (...a: unknown[]) => unknown) {
|
|
25
|
+
captured = handler;
|
|
26
|
+
return 'registered';
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const executor: GogExecutor = vi.fn(async () => 'MOCK_STDOUT');
|
|
30
|
+
|
|
31
|
+
const wrapped = wrapServer(server, executor);
|
|
32
|
+
// The unchanged registrar registers a handler that calls run() — exactly
|
|
33
|
+
// what the real base registrars do.
|
|
34
|
+
const ret = wrapped.registerTool('gog_sheets_get', {}, async () =>
|
|
35
|
+
run(['sheets', 'get', 'A1']),
|
|
36
|
+
);
|
|
37
|
+
expect(ret).toBe('registered'); // the original return flows back through
|
|
38
|
+
|
|
39
|
+
// Invoking the wrapped handler must resolve run()'s executor to ours.
|
|
40
|
+
const out = await captured!({ some: 'args' }, { extra: true });
|
|
41
|
+
expect(out).toBe('MOCK_STDOUT');
|
|
42
|
+
expect(executor).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(executor).toHaveBeenCalledWith(
|
|
44
|
+
['--json', '--color=never', '--no-input', 'sheets', 'get', 'A1'],
|
|
45
|
+
expect.anything(),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('also intercepts the low-level `tool` registration method', async () => {
|
|
50
|
+
stubEnv();
|
|
51
|
+
let captured: ((...a: unknown[]) => unknown) | undefined;
|
|
52
|
+
const server = {
|
|
53
|
+
tool(_name: string, handler: (...a: unknown[]) => unknown) {
|
|
54
|
+
captured = handler;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const executor: GogExecutor = vi.fn(async () => 'VIA_TOOL');
|
|
58
|
+
const wrapped = wrapServer(server, executor);
|
|
59
|
+
wrapped.tool('t', async () => run(['gmail', 'search', 'q']));
|
|
60
|
+
const out = await captured!();
|
|
61
|
+
expect(out).toBe('VIA_TOOL');
|
|
62
|
+
expect(executor).toHaveBeenCalledWith(
|
|
63
|
+
['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'],
|
|
64
|
+
expect.anything(),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('passes a non-function trailing arg straight through (no wrapping)', () => {
|
|
69
|
+
const calls: unknown[][] = [];
|
|
70
|
+
const server = {
|
|
71
|
+
registerTool(...args: unknown[]) {
|
|
72
|
+
calls.push(args);
|
|
73
|
+
return 'ok';
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const executor: GogExecutor = vi.fn();
|
|
77
|
+
const wrapped = wrapServer(server, executor);
|
|
78
|
+
// Only a name, no handler — nothing to wrap.
|
|
79
|
+
expect(wrapped.registerTool('just-a-name')).toBe('ok');
|
|
80
|
+
expect(calls).toEqual([['just-a-name']]);
|
|
81
|
+
expect(executor).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('proxies non-registration properties and methods through unchanged', () => {
|
|
85
|
+
const server = {
|
|
86
|
+
answer: 42,
|
|
87
|
+
greet() {
|
|
88
|
+
return 'hi';
|
|
89
|
+
},
|
|
90
|
+
registerTool() {},
|
|
91
|
+
};
|
|
92
|
+
const wrapped = wrapServer(server, vi.fn());
|
|
93
|
+
expect(wrapped.answer).toBe(42);
|
|
94
|
+
expect(wrapped.greet()).toBe('hi');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('makeFlyExecutor', () => {
|
|
99
|
+
const ENDPOINT = 'https://runner.example';
|
|
100
|
+
const KEY = 'secret-key';
|
|
101
|
+
|
|
102
|
+
it('POSTs the arg-array to /run with the bearer and returns stdout', async () => {
|
|
103
|
+
const fetchMock = vi.fn(async () => ({
|
|
104
|
+
ok: true,
|
|
105
|
+
json: async () => ({ stdout: 'gog output' }),
|
|
106
|
+
}));
|
|
107
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
108
|
+
|
|
109
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
110
|
+
const out = await exec(['sheets', 'get', 'A1'], {});
|
|
111
|
+
expect(out).toBe('gog output');
|
|
112
|
+
|
|
113
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
114
|
+
expect(url).toBe('https://runner.example/run');
|
|
115
|
+
expect(init.method).toBe('POST');
|
|
116
|
+
expect(init.headers).toEqual({
|
|
117
|
+
Authorization: 'Bearer secret-key',
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
});
|
|
120
|
+
expect(init.body).toBe(JSON.stringify({ args: ['sheets', 'get', 'A1'] }));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Everything below is the file-arg wire contract. The Worker has no filesystem
|
|
124
|
+
// and no gog binary, so a GogFileArg must cross the wire STRUCTURED and be
|
|
125
|
+
// materialized on the Fly runner. If this layer ever flattened it back into an
|
|
126
|
+
// argv string, the >4 KiB payload it exists to carry would hit the arg cap again.
|
|
127
|
+
describe('GogFileArg forwarding', () => {
|
|
128
|
+
function bodyOf(fetchMock: { mock: { calls: unknown[][] } }): { args: GogArg[] } {
|
|
129
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
130
|
+
return JSON.parse(init.body as string) as { args: GogArg[] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function okFetch() {
|
|
134
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ stdout: 'ok' }) }));
|
|
135
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
136
|
+
return fetchMock;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('serializes a file arg into the request body with its contents intact', async () => {
|
|
140
|
+
const fetchMock = okFetch();
|
|
141
|
+
// Comfortably past the runner's 4 KiB single-arg cap — the whole point.
|
|
142
|
+
const html = '<p>' + 'x'.repeat(10_000) + '</p>';
|
|
143
|
+
const fileArg: GogFileArg = {
|
|
144
|
+
kind: 'file',
|
|
145
|
+
flag: 'body-html-file',
|
|
146
|
+
contents: html,
|
|
147
|
+
ext: 'html',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
151
|
+
await exec(['gmail', 'drafts', 'create', fileArg], {});
|
|
152
|
+
|
|
153
|
+
const { args } = bodyOf(fetchMock);
|
|
154
|
+
expect(args[3]).toEqual(fileArg);
|
|
155
|
+
// Byte-for-byte: no truncation, no re-encoding, no flattening to argv.
|
|
156
|
+
expect((args[3] as GogFileArg).contents).toBe(html);
|
|
157
|
+
expect((args[3] as GogFileArg).contents).toHaveLength(html.length);
|
|
158
|
+
expect(typeof args[3]).toBe('object');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('preserves order across a mixed array of strings and file args', async () => {
|
|
162
|
+
const fetchMock = okFetch();
|
|
163
|
+
const body: GogFileArg = { kind: 'file', flag: 'body-file', contents: 'plain body' };
|
|
164
|
+
const notes: GogFileArg = {
|
|
165
|
+
kind: 'file',
|
|
166
|
+
flag: 'signature-file',
|
|
167
|
+
contents: 'sig',
|
|
168
|
+
ext: 'html',
|
|
169
|
+
};
|
|
170
|
+
const sent: GogArg[] = [
|
|
171
|
+
'--json',
|
|
172
|
+
'gmail',
|
|
173
|
+
'send',
|
|
174
|
+
'--to=a@example.com',
|
|
175
|
+
body,
|
|
176
|
+
'--subject=Hi',
|
|
177
|
+
notes,
|
|
178
|
+
'--no-input',
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
182
|
+
await exec(sent, {});
|
|
183
|
+
|
|
184
|
+
// Order is load-bearing: gog parses positionally, so a reordered array is
|
|
185
|
+
// a different command.
|
|
186
|
+
expect(bodyOf(fetchMock).args).toEqual(sent);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('round-trips UTF-8 payloads — multibyte, emoji, and interior newlines', async () => {
|
|
190
|
+
const fetchMock = okFetch();
|
|
191
|
+
// Multibyte scripts, an astral-plane emoji (surrogate pair), and combining
|
|
192
|
+
// marks: the classes most likely to be mangled by a naive re-encode.
|
|
193
|
+
const contents = 'héllo wörld — naïve café\n日本語のテキスト\n🎉 emoji + ZWJ 👩💻\né\n';
|
|
194
|
+
const fileArg: GogFileArg = { kind: 'file', flag: 'body-file', contents };
|
|
195
|
+
|
|
196
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
197
|
+
await exec([fileArg], {});
|
|
198
|
+
|
|
199
|
+
const got = bodyOf(fetchMock).args[0] as GogFileArg;
|
|
200
|
+
expect(got.contents).toBe(contents);
|
|
201
|
+
// Interior newlines survive; only gog's own per-command trailing-newline
|
|
202
|
+
// trimming (on the runner) may alter the tail.
|
|
203
|
+
expect(got.contents.split('\n')).toHaveLength(contents.split('\n').length);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('omits ext when the caller omitted it, leaving the default to the runner', async () => {
|
|
207
|
+
const fetchMock = okFetch();
|
|
208
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
209
|
+
await exec([{ kind: 'file', flag: 'note-file', contents: 'n' }], {});
|
|
210
|
+
|
|
211
|
+
const got = bodyOf(fetchMock).args[0] as GogFileArg;
|
|
212
|
+
// Thresholding and defaulting live in ONE place each; this layer must not
|
|
213
|
+
// invent an ext, or two boxes would disagree about the temp filename.
|
|
214
|
+
expect('ext' in got).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('still errors deterministically when a file-arg call fails on the runner', async () => {
|
|
218
|
+
// Classification must not vary with arg shape: a 422 is "gog ran and
|
|
219
|
+
// failed" whether or not the call carried a file arg.
|
|
220
|
+
vi.stubGlobal(
|
|
221
|
+
'fetch',
|
|
222
|
+
vi.fn(async () => ({
|
|
223
|
+
ok: false,
|
|
224
|
+
status: 422,
|
|
225
|
+
json: async () => ({ error: 'use only one of --body-html or --body-html-file' }),
|
|
226
|
+
})),
|
|
227
|
+
);
|
|
228
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
229
|
+
const err = (await exec(
|
|
230
|
+
[{ kind: 'file', flag: 'body-html-file', contents: 'x'.repeat(9000) }],
|
|
231
|
+
{},
|
|
232
|
+
).catch((e: Error) => e)) as Error;
|
|
233
|
+
expect(err.message).toContain('use only one of --body-html');
|
|
234
|
+
expect(err.message).not.toMatch(/retry|transient/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('gives a large payload no extra deadline beyond the standard grace', async () => {
|
|
238
|
+
// Deliberate: upload is a datacenter hop measured in milliseconds, so the
|
|
239
|
+
// runner must still win the race and return a real error.
|
|
240
|
+
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
|
241
|
+
okFetch();
|
|
242
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
243
|
+
await exec([{ kind: 'file', flag: 'body-file', contents: 'y'.repeat(500_000) }], {});
|
|
244
|
+
expect(timeoutSpy).toHaveBeenCalledWith(35_000);
|
|
245
|
+
timeoutSpy.mockRestore();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// A scale-to-zero Fly backend that never answers would otherwise hang the MCP
|
|
250
|
+
// request forever: Workers' fetch has no default deadline, and the stdio path's
|
|
251
|
+
// 30s kill lives in the child process we are NOT spawning here.
|
|
252
|
+
it('arms a client-side deadline so a cold or wedged backend cannot hang forever', async () => {
|
|
253
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ stdout: '' }) }));
|
|
254
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
255
|
+
|
|
256
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
257
|
+
await exec(['x'], {});
|
|
258
|
+
|
|
259
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
260
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('gives the backend its own timeout plus headroom, so the server wins when it can', async () => {
|
|
264
|
+
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
|
265
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ stdout: '' }) })));
|
|
266
|
+
|
|
267
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
268
|
+
await exec(['x'], { timeout: 60_000 });
|
|
269
|
+
|
|
270
|
+
expect(timeoutSpy).toHaveBeenCalledWith(65_000);
|
|
271
|
+
timeoutSpy.mockRestore();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('defaults to the stdio path\'s 30s budget (plus headroom) when no timeout is given', async () => {
|
|
275
|
+
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
|
276
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ stdout: '' }) })));
|
|
277
|
+
|
|
278
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
279
|
+
await exec(['x'], {});
|
|
280
|
+
|
|
281
|
+
expect(timeoutSpy).toHaveBeenCalledWith(35_000);
|
|
282
|
+
timeoutSpy.mockRestore();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('reports an actionable timeout rather than a bare AbortError', async () => {
|
|
286
|
+
vi.stubGlobal(
|
|
287
|
+
'fetch',
|
|
288
|
+
vi.fn(async () => {
|
|
289
|
+
throw Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' });
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
293
|
+
await expect(exec(['x'], {})).rejects.toThrow(/gog-runner did not respond within 35000ms/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('rethrows a non-timeout fetch failure verbatim, not as a timeout', async () => {
|
|
297
|
+
// A real network error (DNS failure, connection refused) rejects with a
|
|
298
|
+
// TypeError named 'TypeError' — neither TimeoutError nor AbortError — so it
|
|
299
|
+
// must pass through untouched rather than be relabelled a timeout.
|
|
300
|
+
const networkErr = new TypeError('fetch failed');
|
|
301
|
+
vi.stubGlobal('fetch', vi.fn(async () => { throw networkErr; }));
|
|
302
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
303
|
+
await expect(exec(['x'], {})).rejects.toBe(networkErr);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('rethrows a non-Error rejection verbatim', async () => {
|
|
307
|
+
// Guards the `err instanceof Error ? err.name : ''` false branch: if fetch
|
|
308
|
+
// ever rejects with a non-Error value, it is rethrown unchanged.
|
|
309
|
+
vi.stubGlobal('fetch', vi.fn(async () => { throw 'kaboom'; }));
|
|
310
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
311
|
+
await expect(exec(['x'], {})).rejects.toBe('kaboom');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// The runner reports "gog ran and exited non-zero" as 422 — deliberately NOT
|
|
315
|
+
// a 5xx, so it can never be confused with Fly's edge failing to reach the
|
|
316
|
+
// Machine, and so it never matches TRANSIENT_ERROR_PATTERN (/\b5\d\d\b/).
|
|
317
|
+
it('surfaces gog stderr verbatim for a 422 and never advises a retry', async () => {
|
|
318
|
+
vi.stubGlobal(
|
|
319
|
+
'fetch',
|
|
320
|
+
vi.fn(async () => ({
|
|
321
|
+
ok: false,
|
|
322
|
+
status: 422,
|
|
323
|
+
json: async () => ({
|
|
324
|
+
error: 'gog exited with code 1',
|
|
325
|
+
stderr: 'invalid attachment id',
|
|
326
|
+
retryable: false,
|
|
327
|
+
}),
|
|
328
|
+
})),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
332
|
+
const err = (await exec(['gmail', 'attachment'], {}).catch((e: Error) => e)) as Error;
|
|
333
|
+
expect(err.message).toContain('gog exited with code 1');
|
|
334
|
+
expect(err.message).toContain('invalid attachment id');
|
|
335
|
+
// Deterministic: retrying cannot help, so nothing may invite it.
|
|
336
|
+
expect(err.message).not.toMatch(/retry/i);
|
|
337
|
+
expect(err.message).not.toMatch(/transient/i);
|
|
338
|
+
// It reached gog — the executor must not claim otherwise.
|
|
339
|
+
expect(err.message).not.toMatch(/never reached gog/i);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// The live repro that started this: an --out path from the caller's sandbox
|
|
343
|
+
// does not exist on the runner, so gog cannot create it. No number of retries
|
|
344
|
+
// makes the directory appear.
|
|
345
|
+
it('reports an unwritable --out path as a plain deterministic gog error', async () => {
|
|
346
|
+
vi.stubGlobal(
|
|
347
|
+
'fetch',
|
|
348
|
+
vi.fn(async () => ({
|
|
349
|
+
ok: false,
|
|
350
|
+
status: 422,
|
|
351
|
+
json: async () => ({
|
|
352
|
+
error: 'mkdir /home/claude: operation not supported',
|
|
353
|
+
stderr: 'mkdir /home/claude: operation not supported',
|
|
354
|
+
retryable: false,
|
|
355
|
+
}),
|
|
356
|
+
})),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
360
|
+
const err = (await exec(['gmail', 'attachment'], {}).catch((e: Error) => e)) as Error;
|
|
361
|
+
expect(err.message).toContain('mkdir /home/claude');
|
|
362
|
+
expect(err.message).not.toMatch(/retry|transient/i);
|
|
363
|
+
// stderr duplicating error must not be echoed twice.
|
|
364
|
+
expect(err.message.match(/mkdir \/home\/claude/g)).toHaveLength(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('marks the runner drain 503 as retryable', async () => {
|
|
368
|
+
vi.stubGlobal(
|
|
369
|
+
'fetch',
|
|
370
|
+
vi.fn(async () => ({
|
|
371
|
+
ok: false,
|
|
372
|
+
status: 503,
|
|
373
|
+
json: async () => ({ error: 'gog-runner is shutting down', retryable: true }),
|
|
374
|
+
})),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
378
|
+
await expect(exec(['gmail', 'attachment'], {})).rejects.toThrow(/restarting; retry/i);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('still advises a retry on a drain 503 whose body is unreadable', async () => {
|
|
382
|
+
// Covers the no-detail arm: a drain that races the response body away is
|
|
383
|
+
// still the runner refusing work on purpose, so it stays retryable.
|
|
384
|
+
vi.stubGlobal(
|
|
385
|
+
'fetch',
|
|
386
|
+
vi.fn(async () => ({
|
|
387
|
+
ok: false,
|
|
388
|
+
status: 503,
|
|
389
|
+
json: async () => {
|
|
390
|
+
throw new Error('not json');
|
|
391
|
+
},
|
|
392
|
+
})),
|
|
393
|
+
);
|
|
394
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
395
|
+
const err = (await exec(['x'], {}).catch((e: Error) => e)) as Error;
|
|
396
|
+
expect(err.message).toBe('gog-runner is restarting; retry this call.');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Fly's edge could not reach the Machine: no runner body at all (an HTML error
|
|
400
|
+
// page or an empty response). This is the ONLY case that is genuinely transient.
|
|
401
|
+
it('names the gateway hop when a 502 carries no runner body', async () => {
|
|
402
|
+
vi.stubGlobal(
|
|
403
|
+
'fetch',
|
|
404
|
+
vi.fn(async () => ({
|
|
405
|
+
ok: false,
|
|
406
|
+
status: 502,
|
|
407
|
+
json: async () => {
|
|
408
|
+
throw new Error('Fly returned HTML, not JSON');
|
|
409
|
+
},
|
|
410
|
+
})),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
414
|
+
await expect(exec(['gmail', 'attachment'], {})).rejects.toThrow(
|
|
415
|
+
/never reached gog.*transient/s,
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Belt and braces for the rollout window (and any future runner that answers
|
|
420
|
+
// 5xx with real detail): if the runner did speak, repeat its words rather than
|
|
421
|
+
// asserting the request never arrived.
|
|
422
|
+
it('repeats the runner detail on a 5xx that does carry a body', async () => {
|
|
423
|
+
vi.stubGlobal(
|
|
424
|
+
'fetch',
|
|
425
|
+
vi.fn(async () => ({
|
|
426
|
+
ok: false,
|
|
427
|
+
status: 502,
|
|
428
|
+
json: async () => ({ error: 'gog exited with code 1', stderr: 'bad flag' }),
|
|
429
|
+
})),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
433
|
+
const err = (await exec(['bogus'], {}).catch((e: Error) => e)) as Error;
|
|
434
|
+
expect(err.message).toContain('gog exited with code 1');
|
|
435
|
+
expect(err.message).toContain('bad flag');
|
|
436
|
+
expect(err.message).not.toMatch(/never reached gog/i);
|
|
437
|
+
// A runner body proves gog ran, so this is deterministic — nothing may
|
|
438
|
+
// invite a retry, exactly as for the 422 path.
|
|
439
|
+
expect(err.message).not.toMatch(/retry|transient/i);
|
|
440
|
+
// And the status digits must not leak into the message: a literal "502"
|
|
441
|
+
// matches TRANSIENT_ERROR_PATTERN downstream and re-attaches the hint.
|
|
442
|
+
expect(err.message).not.toMatch(/\b5\d\d\b/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('falls back to an HTTP-status message when the error body is unreadable', async () => {
|
|
446
|
+
vi.stubGlobal(
|
|
447
|
+
'fetch',
|
|
448
|
+
vi.fn(async () => ({
|
|
449
|
+
ok: false,
|
|
450
|
+
status: 500,
|
|
451
|
+
json: async () => {
|
|
452
|
+
throw new Error('not json');
|
|
453
|
+
},
|
|
454
|
+
})),
|
|
455
|
+
);
|
|
456
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
457
|
+
await expect(exec(['x'], {})).rejects.toThrow('gog-runner HTTP 500');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('handles a 422 whose body is unreadable without pretending it never ran', async () => {
|
|
461
|
+
vi.stubGlobal(
|
|
462
|
+
'fetch',
|
|
463
|
+
vi.fn(async () => ({
|
|
464
|
+
ok: false,
|
|
465
|
+
status: 422,
|
|
466
|
+
json: async () => {
|
|
467
|
+
throw new Error('not json');
|
|
468
|
+
},
|
|
469
|
+
})),
|
|
470
|
+
);
|
|
471
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
472
|
+
await expect(exec(['x'], {})).rejects.toThrow(/gog failed on the runner/i);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { run } from '../src/runner.js';
|
|
4
|
+
import type { Spawner } from '../src/runner.js';
|
|
5
|
+
|
|
6
|
+
// This file mocks node:fs/promises to exercise the two paths real fs won't
|
|
7
|
+
// take on demand: a write that fails, and a cleanup that fails. It lives in
|
|
8
|
+
// its own file because the mock would defeat the byte-level round-trip
|
|
9
|
+
// assertions in runner-file-args.test.ts.
|
|
10
|
+
const mkdtemp = vi.fn(async () => '/tmp/gogcli-mcp-fake');
|
|
11
|
+
const writeFile = vi.fn(async () => {});
|
|
12
|
+
const rm = vi.fn(async () => {});
|
|
13
|
+
|
|
14
|
+
vi.mock('node:fs/promises', () => ({
|
|
15
|
+
mkdtemp: (...args: unknown[]) => mkdtemp(...(args as [])),
|
|
16
|
+
writeFile: (...args: unknown[]) => writeFile(...(args as [])),
|
|
17
|
+
rm: (...args: unknown[]) => rm(...(args as [])),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
function okSpawner(stdout = '{}'): Spawner {
|
|
21
|
+
return vi.fn(() => {
|
|
22
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
23
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
24
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
25
|
+
proc.kill = vi.fn();
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
(proc as unknown as { stdout: EventEmitter }).stdout.emit('data', Buffer.from(stdout));
|
|
28
|
+
proc.emit('close', 0);
|
|
29
|
+
}, 0);
|
|
30
|
+
return proc;
|
|
31
|
+
}) as unknown as Spawner;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fileArg = { kind: 'file', flag: 'body-file', contents: 'payload' } as const;
|
|
35
|
+
|
|
36
|
+
describe('temp-file materialization failures', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
mkdtemp.mockClear();
|
|
39
|
+
writeFile.mockClear();
|
|
40
|
+
rm.mockClear();
|
|
41
|
+
rm.mockImplementation(async () => {});
|
|
42
|
+
writeFile.mockImplementation(async () => {});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('creates NO temp dir when no element is a GogFileArg', async () => {
|
|
46
|
+
// The common path must stay allocation-free: no mkdtemp, no write, no rm.
|
|
47
|
+
await run(['sheets', 'get', 'id1', 'A1'], { spawner: okSpawner() });
|
|
48
|
+
expect(mkdtemp).not.toHaveBeenCalled();
|
|
49
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
50
|
+
expect(rm).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('removes the temp dir and surfaces the error when the write fails', async () => {
|
|
54
|
+
writeFile.mockRejectedValueOnce(new Error('ENOSPC: no space left on device'));
|
|
55
|
+
const spawner = okSpawner();
|
|
56
|
+
|
|
57
|
+
await expect(run(['gmail', 'send', fileArg], { spawner })).rejects.toThrow('ENOSPC');
|
|
58
|
+
expect(rm).toHaveBeenCalledWith('/tmp/gogcli-mcp-fake', { recursive: true, force: true });
|
|
59
|
+
expect(spawner).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not let a cleanup failure mask a successful result', async () => {
|
|
63
|
+
rm.mockRejectedValue(new Error('EBUSY'));
|
|
64
|
+
const result = await run(['gmail', 'send', fileArg], { spawner: okSpawner('{"ok":true}') });
|
|
65
|
+
expect(result).toBe('{"ok":true}');
|
|
66
|
+
expect(rm).toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not let a cleanup failure mask the real gog error', async () => {
|
|
70
|
+
rm.mockRejectedValue(new Error('EBUSY'));
|
|
71
|
+
const spawner = vi.fn(() => {
|
|
72
|
+
const proc = new EventEmitter() as ReturnType<Spawner>;
|
|
73
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stdout = new EventEmitter();
|
|
74
|
+
(proc as unknown as { stdout: EventEmitter; stderr: EventEmitter }).stderr = new EventEmitter();
|
|
75
|
+
proc.kill = vi.fn();
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
(proc as unknown as { stderr: EventEmitter }).stderr.emit('data', Buffer.from('gog: invalid draft'));
|
|
78
|
+
proc.emit('close', 1);
|
|
79
|
+
}, 0);
|
|
80
|
+
return proc;
|
|
81
|
+
}) as unknown as Spawner;
|
|
82
|
+
|
|
83
|
+
await expect(run(['gmail', 'send', fileArg], { spawner })).rejects.toThrow('gog: invalid draft');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('writes the payload with mode 0600 and utf8 encoding', async () => {
|
|
87
|
+
await run(['gmail', 'send', fileArg], { spawner: okSpawner() });
|
|
88
|
+
expect(writeFile).toHaveBeenCalledWith(
|
|
89
|
+
expect.stringContaining('body-file.txt'),
|
|
90
|
+
'payload',
|
|
91
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|