gogcli-mcp 2.20.0 → 2.21.1

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.
@@ -0,0 +1,425 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
3
+
4
+ /**
5
+ * Minting a short-lived access token from a long-lived refresh token is what
6
+ * lets a hosted gog's identity belong to the REGISTRATION instead of to the Fly
7
+ * box's volume (#241). The access token is what crosses the wire; the refresh
8
+ * token never leaves this process.
9
+ *
10
+ * Two properties carry the whole design, and both are about NOT quietly doing
11
+ * the wrong thing:
12
+ *
13
+ * 1. One caller's token must never be served to another. The cache is keyed
14
+ * by the credential, not held as a single "current token" — a Worker
15
+ * isolate serves many callers, and a global would hand the first caller's
16
+ * identity to everyone after them.
17
+ * 2. A failure must fail the CALL. Returning nothing would run the command as
18
+ * the box's identity, and the caller would read someone else's mailbox
19
+ * while everything looked like success.
20
+ */
21
+
22
+ const CLIENT = { GOG_CLIENT_ID: 'cid.apps.googleusercontent.com', GOG_CLIENT_SECRET: 'cs' };
23
+
24
+ function tokenResponse(accessToken: string, expiresIn = 3600) {
25
+ return new Response(JSON.stringify({ access_token: accessToken, expires_in: expiresIn }), {
26
+ status: 200,
27
+ headers: { 'content-type': 'application/json' },
28
+ });
29
+ }
30
+
31
+ beforeEach(() => clearAccessTokenCache());
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ describe('makeAccessTokenSource', () => {
38
+ it('is absent when nothing is configured, so the box keeps acting as itself', () => {
39
+ expect(makeAccessTokenSource({})).toBeUndefined();
40
+ });
41
+
42
+ it('passes through a directly-supplied access token without contacting Google', async () => {
43
+ // The #230 path. Still supported: a caller who already holds a token should
44
+ // not need an OAuth client to use it.
45
+ const fetchMock = vi.fn();
46
+ vi.stubGlobal('fetch', fetchMock);
47
+ const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
48
+ expect(await source()).toBe('ya29.direct');
49
+ expect(fetchMock).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('mints an access token from the refresh token, then serves it from cache', async () => {
53
+ const fetchMock = vi.fn(async () => tokenResponse('ya29.minted'));
54
+ vi.stubGlobal('fetch', fetchMock);
55
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
56
+
57
+ expect(await source()).toBe('ya29.minted');
58
+ expect(await source()).toBe('ya29.minted');
59
+ // A token exchange per tool call would be both slow and a good way to get
60
+ // rate-limited by Google.
61
+ expect(fetchMock).toHaveBeenCalledTimes(1);
62
+
63
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
64
+ expect(url).toBe('https://oauth2.googleapis.com/token');
65
+ const sent = new URLSearchParams(init.body as string);
66
+ expect(sent.get('grant_type')).toBe('refresh_token');
67
+ expect(sent.get('refresh_token')).toBe('rt-1');
68
+ expect(sent.get('client_id')).toBe(CLIENT.GOG_CLIENT_ID);
69
+ });
70
+
71
+ it('never serves one credential holder the other one’s token', async () => {
72
+ // THE one that matters. A module-level "current access token" passes every
73
+ // other test in this file and fails this one — and on the Worker path,
74
+ // where a single isolate serves many callers, that is a cross-account leak
75
+ // rather than a bug.
76
+ const fetchMock = vi
77
+ .fn()
78
+ .mockResolvedValueOnce(tokenResponse('ya29.alice'))
79
+ .mockResolvedValueOnce(tokenResponse('ya29.bob'));
80
+ vi.stubGlobal('fetch', fetchMock);
81
+
82
+ const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
83
+ const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
84
+
85
+ expect(await alice()).toBe('ya29.alice');
86
+ expect(await bob()).toBe('ya29.bob');
87
+ // And each keeps its own on a second read.
88
+ expect(await alice()).toBe('ya29.alice');
89
+ expect(await bob()).toBe('ya29.bob');
90
+ expect(fetchMock).toHaveBeenCalledTimes(2);
91
+ });
92
+
93
+ it('re-mints once the token is close to expiring', async () => {
94
+ vi.useFakeTimers();
95
+ const fetchMock = vi
96
+ .fn()
97
+ .mockResolvedValueOnce(tokenResponse('ya29.first', 3600))
98
+ .mockResolvedValueOnce(tokenResponse('ya29.second', 3600));
99
+ vi.stubGlobal('fetch', fetchMock);
100
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
101
+
102
+ expect(await source()).toBe('ya29.first');
103
+ // Just inside the safety margin: a token that expires mid-flight is a
104
+ // failure the caller cannot do anything about, so it is replaced early.
105
+ vi.advanceTimersByTime((3600 - 60) * 1000);
106
+ expect(await source()).toBe('ya29.second');
107
+ expect(fetchMock).toHaveBeenCalledTimes(2);
108
+ });
109
+
110
+ it('THROWS when the exchange fails, rather than returning nothing', async () => {
111
+ // Returning undefined here would run the command as the box's identity.
112
+ vi.stubGlobal(
113
+ 'fetch',
114
+ vi.fn(async () => new Response(JSON.stringify({ error: 'server_error' }), { status: 500 })),
115
+ );
116
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
117
+ await expect(source()).rejects.toThrow(/could not be refreshed|token exchange/i);
118
+ });
119
+
120
+ it('explains an invalid_grant instead of surfacing a bare OAuth error', async () => {
121
+ vi.stubGlobal(
122
+ 'fetch',
123
+ vi.fn(
124
+ async () =>
125
+ new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Token has been expired or revoked.' }), {
126
+ status: 400,
127
+ }),
128
+ ),
129
+ );
130
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
131
+ await expect(source()).rejects.toThrow(/expired or been revoked|re-?enrol|re-?authoriz/i);
132
+ // The literal token, not just the prose. tools/utils.ts picks the richer
133
+ // INVALID_GRANT_HINT (7-day Testing-mode cause + the headless re-auth pair)
134
+ // by matching `invalid_grant`; a message that only DESCRIBES the failure
135
+ // earns the generic "authentication may have expired" advice instead, which
136
+ // is what this mint used to get while the identical failure reported by gog
137
+ // got the specific guidance.
138
+ await expect(source()).rejects.toThrow(/invalid_grant/);
139
+ });
140
+
141
+ it('does not cache a failure, so a transient outage is not sticky', async () => {
142
+ const fetchMock = vi
143
+ .fn()
144
+ .mockResolvedValueOnce(new Response('{}', { status: 503 }))
145
+ .mockResolvedValueOnce(tokenResponse('ya29.recovered'));
146
+ vi.stubGlobal('fetch', fetchMock);
147
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
148
+
149
+ await expect(source()).rejects.toThrow();
150
+ expect(await source()).toBe('ya29.recovered');
151
+ });
152
+
153
+ it('fails loudly on a half-configured credential instead of acting as the box', async () => {
154
+ // A refresh token with no OAuth client cannot mint anything. Treating it as
155
+ // "unconfigured" would silently fall back to the box's identity, which is
156
+ // the exact confusion this feature exists to remove — so the source exists
157
+ // and throws when used, leaving tools/list working and the reason visible.
158
+ for (const partial of [
159
+ { GOG_REFRESH_TOKEN: 'rt-1' },
160
+ { GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' },
161
+ { GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_SECRET: 'cs' },
162
+ ]) {
163
+ const source = makeAccessTokenSource(partial);
164
+ expect(source).toBeTypeOf('function');
165
+ await expect(source!()).rejects.toThrow(/GOG_CLIENT_ID|GOG_CLIENT_SECRET/);
166
+ }
167
+ });
168
+
169
+ it('says the exchange was unreachable when the network itself fails', async () => {
170
+ // Distinct from a rejection BY Google: nothing was evaluated, so the
171
+ // credential may be perfectly good and the right response is to retry, not
172
+ // to tell the owner to re-enrol.
173
+ vi.stubGlobal(
174
+ 'fetch',
175
+ vi.fn(async () => {
176
+ throw new TypeError('fetch failed');
177
+ }),
178
+ );
179
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
180
+ await expect(source()).rejects.toThrow(/could not be reached.*fetch failed/i);
181
+ });
182
+
183
+ it('survives a thrown non-Error without masking it with a TypeError', async () => {
184
+ // `err.message` on a thrown string is undefined, and the template would
185
+ // then hide the real failure behind a crash inside the error path.
186
+ vi.stubGlobal(
187
+ 'fetch',
188
+ vi.fn(async () => {
189
+ throw 'socket closed';
190
+ }),
191
+ );
192
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
193
+ await expect(source()).rejects.toThrow(/could not be reached.*socket closed/i);
194
+ });
195
+
196
+ it('refuses a 200 that carries no access_token', async () => {
197
+ // A success status with nothing usable in it would otherwise cache
198
+ // `undefined` and send no token at all — a silent downgrade to the
199
+ // backend's identity, wearing a 200.
200
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ expires_in: 3600 }), { status: 200 })));
201
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
202
+ await expect(source()).rejects.toThrow(/no access_token/i);
203
+ });
204
+
205
+ it('assumes an hour when Google omits expires_in', async () => {
206
+ vi.useFakeTimers();
207
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ access_token: 'ya29.nolife' }), { status: 200 }));
208
+ vi.stubGlobal('fetch', fetchMock);
209
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
210
+
211
+ expect(await source()).toBe('ya29.nolife');
212
+ // Still cached half an hour later — i.e. it did not treat "no expiry" as
213
+ // "already expired" and re-mint on every single call.
214
+ vi.advanceTimersByTime(1800 * 1000);
215
+ expect(await source()).toBe('ya29.nolife');
216
+ expect(fetchMock).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ it('reports the status when the body is not JSON at all', async () => {
220
+ // An edge proxy answering a 502 with an HTML error page is the realistic
221
+ // case. Parsing that would throw inside the error path and bury the status,
222
+ // which is the only useful thing such a response carries.
223
+ vi.stubGlobal(
224
+ 'fetch',
225
+ vi.fn(async () => new Response('<html>502 Bad Gateway</html>', { status: 502 })),
226
+ );
227
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
228
+ await expect(source()).rejects.toThrow(/could not be refreshed \(HTTP 502/);
229
+ });
230
+
231
+ it('coalesces concurrent callers into ONE token exchange', async () => {
232
+ // The reported bug was several per-service servers flapping into needs-auth
233
+ // independently. This is the version of that failure that lives in OUR
234
+ // code: `get` → `await exchange` → `set` has an await between the miss and
235
+ // the fill, so N calls arriving together all miss and all exchange.
236
+ //
237
+ // One process per caller hides it; a Worker isolate serving many callers,
238
+ // or simply several tool calls in flight at once, turns one refresh into N
239
+ // simultaneous hits on Google's token endpoint — which is a good way to be
240
+ // rate-limited into exactly the intermittent auth errors being debugged.
241
+ let inFlight = 0;
242
+ let maxConcurrent = 0;
243
+ const fetchMock = vi.fn(async () => {
244
+ inFlight += 1;
245
+ maxConcurrent = Math.max(maxConcurrent, inFlight);
246
+ await new Promise((r) => setTimeout(r, 5));
247
+ inFlight -= 1;
248
+ return tokenResponse('ya29.shared');
249
+ });
250
+ vi.stubGlobal('fetch', fetchMock);
251
+
252
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
253
+ const results = await Promise.all(Array.from({ length: 8 }, () => source()));
254
+
255
+ expect(results).toEqual(Array(8).fill('ya29.shared'));
256
+ expect(fetchMock).toHaveBeenCalledTimes(1);
257
+ expect(maxConcurrent).toBe(1);
258
+ });
259
+
260
+ it('lets a later caller retry after a concurrent exchange failed', async () => {
261
+ // Coalescing must not make one failure permanent for everyone: the shared
262
+ // attempt is dropped when it settles, so the next call starts a fresh one.
263
+ // The failure has to take a tick. An exchange that rejects instantly can
264
+ // finish and clear itself before the second caller even looks, so that
265
+ // caller correctly starts its own attempt — which would make this test pass
266
+ // without any sharing having happened. A real exchange is a network round
267
+ // trip, so the shared-failure case is the one worth pinning.
268
+ const fetchMock = vi
269
+ .fn()
270
+ .mockImplementationOnce(async () => {
271
+ await new Promise((r) => setTimeout(r, 5));
272
+ throw new Error('boom');
273
+ })
274
+ .mockResolvedValueOnce(tokenResponse('ya29.after'));
275
+ vi.stubGlobal('fetch', fetchMock);
276
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
277
+
278
+ const settled = await Promise.allSettled([source(), source()]);
279
+ expect(settled.every((s) => s.status === 'rejected')).toBe(true);
280
+ // Both shared ONE failed exchange rather than each making their own...
281
+ expect(fetchMock).toHaveBeenCalledTimes(1);
282
+ // ...and the failure was not cached, so the next caller recovers.
283
+ expect(await source()).toBe('ya29.after');
284
+ });
285
+
286
+ it('does not let two different credentials share one in-flight exchange', async () => {
287
+ // Answers are keyed off the REQUEST's refresh_token, never off call order.
288
+ //
289
+ // `mockResolvedValueOnce` twice would assign alice's token to whichever
290
+ // exchange reaches fetch first, and that is not the order the sources were
291
+ // called in: each one awaits `crypto.subtle.digest` (via the cache key)
292
+ // before it reaches fetch, and two digest promises are not guaranteed to
293
+ // settle in the order they were started. Measured against this very source,
294
+ // bob's request went first in 4 of 400 races (~1%), which is exactly the
295
+ // rate at which an order-keyed mock hands alice bob's token and reddens CI
296
+ // for a reason that has nothing to do with the behaviour under test.
297
+ //
298
+ // Keying off the body also makes the assertion say what it means: the point
299
+ // is that each credential got ITS OWN token, which is a claim about
300
+ // pairing, not about sequence.
301
+ const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
302
+ const refreshToken = new URLSearchParams(init.body).get('refresh_token');
303
+ return tokenResponse(refreshToken === 'rt-alice' ? 'ya29.alice' : 'ya29.bob');
304
+ });
305
+ vi.stubGlobal('fetch', fetchMock);
306
+ const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
307
+ const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
308
+
309
+ const [a, b] = await Promise.all([alice(), bob()]);
310
+ expect(a).toBe('ya29.alice');
311
+ expect(b).toBe('ya29.bob');
312
+ expect(fetchMock).toHaveBeenCalledTimes(2);
313
+ });
314
+
315
+ it('never puts the refresh token in the error it throws', async () => {
316
+ vi.stubGlobal(
317
+ 'fetch',
318
+ vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })),
319
+ );
320
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-super-secret-value' })!;
321
+ await expect(source()).rejects.toThrow(
322
+ expect.not.stringContaining('rt-super-secret-value') as unknown as string,
323
+ );
324
+ });
325
+ });
326
+
327
+ /**
328
+ * A token Google has REJECTED must leave the cache.
329
+ *
330
+ * The cache's only read guard is time-based (`expiresAt - EXPIRY_MARGIN_MS >
331
+ * Date.now()`), so before this existed a token Google answered 401 to was
332
+ * re-served for the rest of its nominal hour — every call in that window failed
333
+ * identically, retrying changed nothing, and only reconnecting (a fresh isolate
334
+ * with an empty cache) helped.
335
+ *
336
+ * The eviction is deliberately NOT `clearAccessTokenCache()`. That is a test
337
+ * seam which drops EVERY credential; using it here would mean one caller's dead
338
+ * token forced a re-mint on every other caller sharing the isolate — a new bug
339
+ * in the same shape as the ones this module was built to avoid.
340
+ */
341
+ describe('invalidate', () => {
342
+ it('evicts the rejected token so the next call mints a fresh one', async () => {
343
+ const fetchMock = vi
344
+ .fn()
345
+ .mockResolvedValueOnce(tokenResponse('ya29.first'))
346
+ .mockResolvedValueOnce(tokenResponse('ya29.second'));
347
+ vi.stubGlobal('fetch', fetchMock);
348
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
349
+
350
+ expect(await source()).toBe('ya29.first');
351
+ expect(await source()).toBe('ya29.first');
352
+ expect(fetchMock).toHaveBeenCalledTimes(1);
353
+
354
+ // True: something was actually dropped, so a retry has a chance of using a
355
+ // DIFFERENT token. That answer is what the caller's retry decision hangs on.
356
+ expect(await source.invalidate('ya29.first')).toBe(true);
357
+
358
+ expect(await source()).toBe('ya29.second');
359
+ expect(fetchMock).toHaveBeenCalledTimes(2);
360
+ });
361
+
362
+ it('evicts only the credential whose token was rejected', async () => {
363
+ // The multi-tenant property. A Worker isolate serves many callers; one
364
+ // caller meeting a dead token must not cost everyone else a re-mint.
365
+ const fetchMock = vi
366
+ .fn()
367
+ .mockResolvedValueOnce(tokenResponse('ya29.alice'))
368
+ .mockResolvedValueOnce(tokenResponse('ya29.bob'));
369
+ vi.stubGlobal('fetch', fetchMock);
370
+ const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
371
+ const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
372
+
373
+ expect(await alice()).toBe('ya29.alice');
374
+ expect(await bob()).toBe('ya29.bob');
375
+ expect(await alice.invalidate('ya29.alice')).toBe(true);
376
+
377
+ // Bob's entry is untouched — still served from cache, no third exchange.
378
+ expect(await bob()).toBe('ya29.bob');
379
+ expect(fetchMock).toHaveBeenCalledTimes(2);
380
+ });
381
+
382
+ it('ignores a rejection naming a token that is no longer the cached one', async () => {
383
+ // The ABA case. Caller A sends token T; while A is in flight, B refreshes
384
+ // the entry to T2; A comes back with "T was rejected". Dropping T2 there
385
+ // would make the next call mint a third token for nothing, and two callers
386
+ // could keep evicting each other's work indefinitely.
387
+ const fetchMock = vi.fn(async () => tokenResponse('ya29.current'));
388
+ vi.stubGlobal('fetch', fetchMock);
389
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
390
+
391
+ expect(await source()).toBe('ya29.current');
392
+ expect(await source.invalidate('ya29.superseded')).toBe(false);
393
+ expect(await source()).toBe('ya29.current');
394
+ expect(fetchMock).toHaveBeenCalledTimes(1);
395
+ });
396
+
397
+ it('reports nothing evicted when the credential has no cached token at all', async () => {
398
+ vi.stubGlobal('fetch', vi.fn());
399
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
400
+ expect(await source.invalidate('ya29.never-minted')).toBe(false);
401
+ });
402
+
403
+ it('offers no eviction at all for a directly-supplied token, which cannot be re-minted', async () => {
404
+ // The #230 path stores an ACCESS token, so there is nothing to mint from:
405
+ // reading again returns the identical string.
406
+ //
407
+ // The ABSENCE of the method is the signal, not a `false` return. A source
408
+ // that answers false is indistinguishable from a mintable one whose token a
409
+ // concurrent caller already replaced, and the connector logs those two as
410
+ // different causes — so a source that attached an always-false `invalidate`
411
+ // made the auth log blame concurrency for "nothing here can mint a
412
+ // replacement", which is the one case where re-authorizing IS the repair.
413
+ const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
414
+ expect(source.invalidate).toBeUndefined();
415
+ expect(await source()).toBe('ya29.direct');
416
+ });
417
+
418
+ it('offers no eviction for a source that cannot mint at all', async () => {
419
+ // GOG_REFRESH_TOKEN without an OAuth client: the source exists only to fail
420
+ // loudly on first use, so there is never a cached token behind it.
421
+ const source = makeAccessTokenSource({ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' })!;
422
+ expect(source.invalidate).toBeUndefined();
423
+ await expect(source()).rejects.toThrow(/GOG_CLIENT_SECRET/);
424
+ });
425
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { spawn as mockedSpawn } from 'node:child_process';
4
- import { run, runBinary, runExecutor } from '../src/runner.js';
4
+ import { run, runBinary, runExecutor, RunnerTransportError, isRunnerTransportError } from '../src/runner.js';
5
5
  import type { Spawner, GogExecutor } from '../src/runner.js';
6
6
 
7
7
  // The real spawn is dynamically imported inside runner's default executor.
@@ -763,6 +763,26 @@ describe('run executor seam', () => {
763
763
  }
764
764
  });
765
765
 
766
+ // run() re-wraps every thrown error to redact secrets from its message. That
767
+ // rewrap must not cost a RunnerTransportError its TYPE: the type is the only
768
+ // thing that tells diagnose() the failure was the connector's transport and
769
+ // not the caller's Google credential, and a bare Error puts it straight back
770
+ // to guessing from prose.
771
+ it('preserves a RunnerTransportError through the redacting rewrap', async () => {
772
+ const executor = vi.fn(async () => {
773
+ throw new RunnerTransportError('runner key mismatch; saw 1//0eLEAKED-REFRESH end', 'transport-auth', 401);
774
+ }) as unknown as GogExecutor;
775
+ const err = await runExecutor
776
+ .run({ executor }, () => run(['gmail', 'get', 'm1']))
777
+ .catch((e: unknown) => e);
778
+ expect(isRunnerTransportError(err)).toBe(true);
779
+ expect((err as RunnerTransportError).kind).toBe('transport-auth');
780
+ expect((err as RunnerTransportError).status).toBe(401);
781
+ // and it is still redacted
782
+ expect((err as Error).message).not.toContain('1//0eLEAKED-REFRESH');
783
+ expect((err as Error).message).toContain('[REDACTED]');
784
+ });
785
+
766
786
  it('options.spawner takes precedence over an injected ALS executor', async () => {
767
787
  const spawner = makeSpawner(0, '{"via":"spawner"}');
768
788
  const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createRequire } from 'node:module';
3
+ import { realpathSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // Guards the invariant that broke dependabot #220: the whole monorepo must
7
+ // resolve ONE copy of @modelcontextprotocol/sdk.
8
+ //
9
+ // `McpServer` carries a `private _serverInfo`, so TypeScript compares it
10
+ // NOMINALLY, not structurally. Two installed copies therefore become two
11
+ // mutually-unassignable classes, and every `ToolRegistrar` in server.ts fails
12
+ // with TS2322 — with no API change and no source change anywhere. #220 split
13
+ // the tree exactly that way: `agents` (a root devDependency, the Worker
14
+ // connector's McpAgent) exact-pins the SDK to 1.29.0 and so takes the hoisted
15
+ // root slot that `@chrischall/mcp-utils` resolves its peer from, while the
16
+ // workspaces asking for ^1.30.0 each nested their own copy.
17
+ //
18
+ // This asserts resolution identity rather than a version string: the failure is
19
+ // "two copies", not "the wrong version", and pinning a version here would just
20
+ // have to be edited on every future bump.
21
+ describe('@modelcontextprotocol/sdk is installed exactly once', () => {
22
+ const here = createRequire(import.meta.url);
23
+
24
+ // An exported subpath — the SDK's `exports` map does not expose package.json.
25
+ const SDK_SUBPATH = '@modelcontextprotocol/sdk/server/mcp.js';
26
+
27
+ // `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
28
+ // entry: these packages are ESM-only, so their `exports` maps carry no
29
+ // `require` condition and CJS resolution of the bare specifier throws.
30
+ const resolveFrom = (specifier: string): string =>
31
+ realpathSync(
32
+ createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(SDK_SUBPATH),
33
+ );
34
+
35
+ it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
36
+ // mcp-utils declares the SDK as a peer and hands our registrars the
37
+ // McpServer it built, so its copy is the one they must be typed against.
38
+ expect(resolveFrom('@chrischall/mcp-utils')).toBe(
39
+ realpathSync(here.resolve(SDK_SUBPATH)),
40
+ );
41
+ });
42
+
43
+ it('resolves to the same file for `agents`, which exact-pins the SDK', () => {
44
+ // The Worker connector builds its McpServer via McpAgent from `agents`.
45
+ // An exact pin there is what captured the root hoist slot in #220.
46
+ expect(resolveFrom('agents')).toBe(realpathSync(here.resolve(SDK_SUBPATH)));
47
+ });
48
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diagnose } from '../../src/tools/utils.js';
3
+
4
+ /**
5
+ * `401` is a status code, but it is also just a number, and gog's output is
6
+ * full of numbers that are row indices, ranges and counts. The pattern used to
7
+ * be a bare `\b401\b`, so "row 401 is outside the sheet grid" — a pure Sheets
8
+ * range error — told the caller to re-authorize a healthy Google account.
9
+ *
10
+ * That is the same defect this PR exists to remove, reached from gog's stderr
11
+ * instead of the runner's status line: a caller is sent to do a manual,
12
+ * account-wide re-auth that cannot possibly fix their problem.
13
+ *
14
+ * So a 401 now has to look like a STATUS, not like an integer.
15
+ */
16
+ const reauth = async (msg: string) => {
17
+ const r = await diagnose(new Error(msg));
18
+ return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
19
+ };
20
+
21
+ describe('401 must look like a status, not any integer', () => {
22
+ it.each([
23
+ ['row 401 is outside the sheet grid'],
24
+ ['wrote 401 rows'],
25
+ ['A401:B401 exceeds grid limits'],
26
+ ['deleted 401 messages'],
27
+ ['sheet has 401 columns'],
28
+ ])('does NOT claim an auth failure for: %s', async (msg) => {
29
+ expect(await reauth(msg)).toBe(false);
30
+ });
31
+
32
+ it.each([
33
+ ['googleapi: Error 401: Invalid Credentials, authError'],
34
+ ['HTTP 401 Unauthorized'],
35
+ ['request failed with status 401'],
36
+ ['unexpected status code 401'],
37
+ ['401 Unauthorized'],
38
+ ['server responded 401: token rejected'],
39
+ ])('still DOES claim an auth failure for: %s', async (msg) => {
40
+ expect(await reauth(msg)).toBe(true);
41
+ });
42
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diagnose } from '../../src/tools/utils.js';
3
+
4
+ /**
5
+ * #246: making 401 "look like a status" (#245 follow-up) required a separator
6
+ * between the status word and the number. `\s*[:=]?\s*` cannot cross an opening
7
+ * paren or a JSON quote, so the CANONICAL shape gog emits for a Google auth
8
+ * failure stopped matching entirely:
9
+ *
10
+ * Google API error (401 authError): Invalid Credentials
11
+ *
12
+ * That shape is hard-coded in connector-runtime.ts as /Google API error \(401\b/
13
+ * and used as the fixture across auth-log and connector-runtime tests. Losing it
14
+ * is strictly worse than the `row 401` false positive that motivated the change:
15
+ * a false positive sends someone on a pointless re-auth, but this leaves a REAL
16
+ * dead credential with no hint at all.
17
+ */
18
+ const reauth = async (msg: string) => {
19
+ const r = await diagnose(new Error(msg));
20
+ return /Use gog_auth_add to re-authorize the account/i.test(r.content.map((c: any) => c.text).join('\n'));
21
+ };
22
+
23
+ describe('401 shapes that ARE auth failures', () => {
24
+ it.each([
25
+ ['Google API error (401 authError): Invalid Credentials'], // the canonical gog shape
26
+ ['Google API error (401): Invalid Credentials'],
27
+ ['{"code": 401, "message": "Invalid Credentials"}'], // JSON body
28
+ ['{"status":401}'],
29
+ ['googleapi: Error 401: Invalid Credentials, authError'],
30
+ ['HTTP 401 Unauthorized'],
31
+ ['request failed with status 401'],
32
+ ['unexpected status code 401'],
33
+ ['response=401'],
34
+ ])('claims an auth failure for: %s', async (msg) => {
35
+ expect(await reauth(msg)).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe('401 shapes that are NOT auth failures', () => {
40
+ it.each([
41
+ ['row 401 is outside the sheet grid'],
42
+ ['wrote 401 rows'],
43
+ ['A401:B401 exceeds grid limits'],
44
+ ['deleted 401 messages'],
45
+ ['sheet has 401 columns'],
46
+ ['error: could not write row 401'], // status word present, but far from the number
47
+ ])('stays silent for: %s', async (msg) => {
48
+ expect(await reauth(msg)).toBe(false);
49
+ });
50
+ });
@@ -25,6 +25,27 @@ describe('gog_auth_list', () => {
25
25
  expect(result.content[0].text).toBe('Error: No accounts configured');
26
26
  });
27
27
 
28
+ it('does not advertise itself as proof the account still works', async () => {
29
+ // `gog auth list` reads the keyring. No network, no validation — it lists a
30
+ // full scope set for an account whose refresh token died days ago.
31
+ //
32
+ // This description used to say "check which accounts are configured and
33
+ // available", and "available" is exactly the wrong word: it was read as a
34
+ // liveness check while per-service servers were flapping into needs-auth,
35
+ // which pointed a whole debugging session away from an expired grant.
36
+ // gog_auth_health is the tool that actually probes Google.
37
+ const harness = await setupHandlers();
38
+ const { tools } = await harness.client.listTools();
39
+ const desc = tools.find((t) => t.name === 'gog_auth_list')!.description!;
40
+
41
+ expect(desc).not.toMatch(/\bavailable\b/i);
42
+ // It must say what it does NOT do, and where to go instead — a reader who
43
+ // wants liveness has to be sent somewhere, or they will use this anyway.
44
+ expect(desc).toMatch(/does not|without/i);
45
+ expect(desc).toContain('gog_auth_health');
46
+ await harness.close();
47
+ });
48
+
28
49
  it('handles non-Error rejection', async () => {
29
50
  vi.mocked(runner.run).mockRejectedValue('something went wrong');
30
51
  const harness = await setupHandlers();