gogcli-mcp 2.21.0 → 2.22.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/src/worker.ts CHANGED
@@ -16,7 +16,7 @@ import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-
16
16
  import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
17
17
  import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
18
18
  import { makeFlyExecutor, wrapServer } from './connector-runtime.js';
19
- import { gogAuth, type GogProps } from './connector-auth.js';
19
+ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth.js';
20
20
 
21
21
  // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
22
  //
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.21.0'; // x-release-please-version
41
+ const VERSION = '2.22.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -46,8 +46,38 @@ const VERSION = '2.21.0'; // x-release-please-version
46
46
  // `agents` runtime; the node-testable helpers stay in connector-runtime.ts.)
47
47
  function makeAgent(registrars: ToolRegistrar[]): typeof McpAgent {
48
48
  class GogAgent extends McpAgent<unknown, unknown, GogProps> {
49
- server = new McpServer({ name: 'gogcli-mcp', version: VERSION });
49
+ // `instructions` is the connector's only channel to the model that is not a
50
+ // tool description, and it carries the one thing the client UI gets wrong:
51
+ // "connected"/"refreshed" is a statement about the connector key, not about
52
+ // Google. See CONNECTOR_INSTRUCTIONS for why that has to be said out loud.
53
+ server = new McpServer(
54
+ { name: 'gogcli-mcp', version: VERSION },
55
+ { instructions: CONNECTOR_INSTRUCTIONS },
56
+ );
50
57
  async init() {
58
+ // NO third argument, deliberately: the hosted connector supplies no
59
+ // per-caller access token, so `gog` runs as the Fly volume's own identity
60
+ // and refreshes from its own keyring. That is what makes the eviction +
61
+ // replay machinery in connector-runtime.ts INERT here — with no token
62
+ // source there is no module-level cache that can go stale, so a Google
63
+ // 401 on this path stops at the `no access token was supplied` guard and
64
+ // logs `replay.declined`. That record is the expected outcome for a
65
+ // hosted connector, not a bug; the transport-failure classification and
66
+ // the auth log itself do apply here.
67
+ //
68
+ // Inert is not the same as unobserved. Because `gog` is spawned fresh per
69
+ // /run and re-reads the keyring each time, a Google 401 here means the
70
+ // STORED credential was refused — which no retry can repair, so no retry
71
+ // is built. Instead that same guard first takes one live reading of the
72
+ // Google layer (`GET /health/google` on the runner) and records it as
73
+ // `refusal.google-ok` / `-unhealthy` / `-unmeasured`. It is throttled,
74
+ // deadline-bounded, cannot throw, and leaves the caller's error
75
+ // byte-identical; its whole job is to answer, in the log, the question
76
+ // that could not be answered after the incident: at the moment Google
77
+ // refused, was the refresh token on the volume alive or dead?
78
+ // (docs/DEPLOY-CONNECTOR.md, "Reading the auth log" and "Why a hosted
79
+ // Google 401 is measured rather than retried", says this for whoever is
80
+ // reading logs rather than code.)
51
81
  const executor = makeFlyExecutor((this.env as { FLY_ENDPOINT: string }).FLY_ENDPOINT, this.props.key);
52
82
  const wrapped = wrapServer(this.server, executor);
53
83
  for (const register of registrars) register(wrapped);
@@ -0,0 +1,530 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { logAuthTransition, credentialTag } from '../src/auth-log.js';
3
+ import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
4
+ import { makeFlyExecutor } from '../src/connector-runtime.js';
5
+
6
+ /**
7
+ * DEFECT 4: the auth path completed in total silence.
8
+ *
9
+ * Every outcome that mattered — a transport 401 from the runner's own bearer
10
+ * check, a token minted, a token served from cache, a token evicted because
11
+ * Google rejected it, a dead refresh grant — happened with no record anywhere.
12
+ * `wrangler.jsonc` sets `observability.enabled = true`, so the Worker has had a
13
+ * live Workers Logs sink since it shipped, and nothing ever wrote to it. The
14
+ * owner could not correlate the reported incident against anything because
15
+ * nothing recorded an auth-state transition.
16
+ *
17
+ * Two properties are being pinned here, and the second is the hard one:
18
+ *
19
+ * 1. Each transition emits exactly one record, carrying WHEN, WHICH
20
+ * credential (as a non-reversible tag), WHAT changed, and WHY.
21
+ * 2. No credential material can reach a log line. Not the refresh token, not
22
+ * the client secret, not the access token — including when a token is
23
+ * quoted verbatim inside an error message this module is asked to record.
24
+ */
25
+
26
+ const CLIENT = {
27
+ GOG_CLIENT_ID: 'cid.apps.googleusercontent.com',
28
+ GOG_CLIENT_SECRET: 'cs-super-secret-client-secret',
29
+ };
30
+ const REFRESH = 'rt-super-secret-refresh-value';
31
+ const ACCESS_1 = 'ya29.super-secret-access-token-one';
32
+ const ACCESS_2 = 'ya29.super-secret-access-token-two';
33
+
34
+ /** Every string this module can be blamed for putting in front of a human. */
35
+ const CREDENTIAL_MATERIAL = [REFRESH, CLIENT.GOG_CLIENT_SECRET, ACCESS_1, ACCESS_2];
36
+
37
+ const PREFIX = 'gog-auth ';
38
+
39
+ interface Emitted {
40
+ method: 'warn' | 'error';
41
+ line: string;
42
+ }
43
+
44
+ /**
45
+ * Capture the two stderr-safe console methods, and separately capture the four
46
+ * that Node routes to STDOUT — because stdout is the JSON-RPC channel on the
47
+ * stdio transport, so a single `console.log` there corrupts the protocol. The
48
+ * test asserts the stdout set stays empty.
49
+ */
50
+ function captureLog() {
51
+ const emitted: Emitted[] = [];
52
+ const toStdout: string[] = [];
53
+ for (const method of ['warn', 'error'] as const) {
54
+ vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
55
+ emitted.push({ method, line: args.map(String).join(' ') });
56
+ });
57
+ }
58
+ for (const method of ['log', 'info', 'debug', 'trace'] as const) {
59
+ vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
60
+ toStdout.push(args.map(String).join(' '));
61
+ });
62
+ }
63
+ return {
64
+ emitted,
65
+ toStdout,
66
+ /** Every emitted line, parsed back out of its `gog-auth {...}` envelope. */
67
+ records(): Record<string, unknown>[] {
68
+ return emitted.map((e) => {
69
+ expect(e.line.startsWith(PREFIX)).toBe(true);
70
+ return JSON.parse(e.line.slice(PREFIX.length)) as Record<string, unknown>;
71
+ });
72
+ },
73
+ events(): unknown[] {
74
+ return this.records().map((r) => r.event);
75
+ },
76
+ /** Everything written anywhere, for the leak assertion. */
77
+ allText(): string {
78
+ return [...emitted.map((e) => e.line), ...toStdout].join('\n');
79
+ },
80
+ };
81
+ }
82
+
83
+ function tokenResponse(accessToken: string, expiresIn = 3600) {
84
+ return new Response(JSON.stringify({ access_token: accessToken, expires_in: expiresIn }), {
85
+ status: 200,
86
+ headers: { 'content-type': 'application/json' },
87
+ });
88
+ }
89
+
90
+ beforeEach(() => clearAccessTokenCache());
91
+ afterEach(() => {
92
+ vi.restoreAllMocks();
93
+ vi.unstubAllGlobals();
94
+ vi.unstubAllEnvs();
95
+ });
96
+
97
+ describe('logAuthTransition', () => {
98
+ it('writes one greppable JSON record carrying when, which credential, what and why', () => {
99
+ const log = captureLog();
100
+ logAuthTransition('token.minted', {
101
+ credential: 'a1b2c3d4e5f6',
102
+ service: 'gmail',
103
+ reason: 'minted, expires in 3600s',
104
+ });
105
+
106
+ expect(log.emitted).toHaveLength(1);
107
+ const [record] = log.records();
108
+ expect(record.event).toBe('token.minted');
109
+ expect(record.credential).toBe('a1b2c3d4e5f6');
110
+ expect(record.service).toBe('gmail');
111
+ expect(record.reason).toBe('minted, expires in 3600s');
112
+ // A timestamp, because correlating an incident against a log with no clock
113
+ // is the thing that could not be done before.
114
+ expect(Date.parse(record.at as string)).not.toBeNaN();
115
+ });
116
+
117
+ it('never writes to stdout, which is the JSON-RPC channel on the stdio transport', () => {
118
+ const log = captureLog();
119
+ logAuthTransition('token.cache-hit', { credential: 'abc' });
120
+ logAuthTransition('grant.dead', { credential: 'abc', reason: 'invalid_grant' });
121
+ expect(log.toStdout).toEqual([]);
122
+ expect(log.emitted).toHaveLength(2);
123
+ });
124
+
125
+ it('routes routine transitions and failures to different console levels', () => {
126
+ const log = captureLog();
127
+ logAuthTransition('token.cache-hit', { credential: 'abc' });
128
+ logAuthTransition('runner.auth-failed', { endpoint: 'https://runner.example' });
129
+ expect(log.emitted.map((e) => e.method)).toEqual(['warn', 'error']);
130
+ });
131
+
132
+ it('separates a MEASURED dead Google layer from one nobody could measure', () => {
133
+ // The connect-time probe has three honest answers, and conflating the last
134
+ // two is exactly the defect it exists to remove: "I asked Google and it said
135
+ // no" is a failure, while "I could not ask" is not evidence of anything.
136
+ const log = captureLog();
137
+ logAuthTransition('connect.google-ok', { endpoint: 'https://runner.example' });
138
+ logAuthTransition('connect.google-unhealthy', { endpoint: 'https://runner.example', reason: 'invalid_grant' });
139
+ logAuthTransition('connect.google-unmeasured', { endpoint: 'https://runner.example', reason: 'HTTP 404' });
140
+
141
+ expect(log.events()).toEqual(['connect.google-ok', 'connect.google-unhealthy', 'connect.google-unmeasured']);
142
+ expect(log.emitted.map((e) => e.method)).toEqual(['warn', 'error', 'warn']);
143
+ expect(log.toStdout).toEqual([]);
144
+ });
145
+
146
+ it('omits absent context rather than writing nulls', () => {
147
+ const log = captureLog();
148
+ logAuthTransition('token.evicted', { credential: 'abc' });
149
+ const [record] = log.records();
150
+ expect(Object.keys(record).sort()).toEqual(['at', 'credential', 'event']);
151
+ });
152
+
153
+ it('redacts a Google token quoted inside a reason it is asked to record', () => {
154
+ // The reason strings are built from error text — gog's stderr, Google's
155
+ // response — which this layer does not author and cannot vet. So the whole
156
+ // serialized line goes through the repo's existing redactor.
157
+ const log = captureLog();
158
+ logAuthTransition('replay.failed', {
159
+ credential: 'abc',
160
+ reason: `Google API error (401): token ${ACCESS_1} was refused; refresh 1//0gLeAkEdReFrEsH also shown`,
161
+ });
162
+ const line = log.allText();
163
+ expect(line).not.toContain(ACCESS_1);
164
+ expect(line).not.toContain('1//0gLeAkEdReFrEsH');
165
+ expect(line).toContain('[REDACTED]');
166
+ // Still a readable record, not a mangled one.
167
+ expect(log.records()[0].event).toBe('replay.failed');
168
+ });
169
+ });
170
+
171
+ describe('credentialTag', () => {
172
+ it('is a short, stable, non-reversible slice of the hash that already keys the cache', () => {
173
+ const hash = 'f'.repeat(64);
174
+ expect(credentialTag(hash)).toBe('f'.repeat(12));
175
+ expect(credentialTag(hash)).toBe(credentialTag(hash));
176
+ });
177
+ });
178
+
179
+ describe('google-token records every access-token transition', () => {
180
+ it('names minted, cache-hit, evicted and evict-noop, tagged with one stable credential', async () => {
181
+ let minted = 0;
182
+ vi.stubGlobal(
183
+ 'fetch',
184
+ vi.fn(async () => tokenResponse((minted += 1) === 1 ? ACCESS_1 : ACCESS_2)),
185
+ );
186
+ const log = captureLog();
187
+ const source = makeAccessTokenSource({
188
+ ...CLIENT,
189
+ GOG_REFRESH_TOKEN: REFRESH,
190
+ GOG_AUTH_LOG_CACHE_HITS: '1',
191
+ })!;
192
+
193
+ expect(await source()).toBe(ACCESS_1);
194
+ expect(await source()).toBe(ACCESS_1);
195
+ expect(await source.invalidate!(ACCESS_1)).toBe(true);
196
+ expect(await source.invalidate!(ACCESS_1)).toBe(false);
197
+
198
+ expect(log.events()).toEqual([
199
+ 'token.minted',
200
+ 'token.cache-hit',
201
+ 'token.evicted',
202
+ 'token.evict-noop',
203
+ ]);
204
+ // One credential, one tag, on every record — this is what makes a sequence
205
+ // readable as a story about ONE account rather than four unrelated lines.
206
+ const tags = new Set(log.records().map((r) => r.credential));
207
+ expect(tags.size).toBe(1);
208
+ expect(await source.credentialId!()).toBe([...tags][0]);
209
+ });
210
+
211
+ it('says nothing on a cache hit unless asked, so the stream stays a log of transitions', async () => {
212
+ // A cache hit is the STEADY STATE, not an event: narrating it writes one
213
+ // Workers Logs line (and its cost) per gog invocation on the Worker, and one
214
+ // stderr line per invocation in the MCP host's server log on stdio. It stays
215
+ // available for an investigation that has to prove which token a call was
216
+ // served, behind a flag nobody sets in normal operation.
217
+ vi.stubGlobal('fetch', vi.fn(async () => tokenResponse(ACCESS_1)));
218
+ const log = captureLog();
219
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
220
+ expect(await source()).toBe(ACCESS_1);
221
+ expect(await source()).toBe(ACCESS_1);
222
+ expect(await source()).toBe(ACCESS_1);
223
+ expect(log.events()).toEqual(['token.minted']);
224
+ });
225
+
226
+ it('distinguishes a superseded token from a credential with nothing cached', async () => {
227
+ vi.stubGlobal('fetch', vi.fn(async () => tokenResponse(ACCESS_1)));
228
+ const log = captureLog();
229
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
230
+ await source();
231
+ // The ABA case: a concurrent caller already replaced the entry.
232
+ expect(await source.invalidate!(ACCESS_2)).toBe(false);
233
+ const reasons = log.records().map((r) => r.reason);
234
+ expect(reasons[1]).toMatch(/already replaced/i);
235
+ });
236
+
237
+ it('separates a dead refresh grant from an ordinary mint failure', async () => {
238
+ vi.stubGlobal(
239
+ 'fetch',
240
+ vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })),
241
+ );
242
+ const dead = captureLog();
243
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
244
+ await expect(source()).rejects.toThrow(/re-authorized/);
245
+ expect(dead.events()).toEqual(['grant.dead']);
246
+ expect(dead.emitted[0].method).toBe('error');
247
+
248
+ vi.restoreAllMocks();
249
+ clearAccessTokenCache();
250
+ vi.stubGlobal(
251
+ 'fetch',
252
+ vi.fn(async () => new Response(JSON.stringify({ error: 'backend_error' }), { status: 500 })),
253
+ );
254
+ const failed = captureLog();
255
+ const other = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
256
+ await expect(other()).rejects.toThrow(/could not be refreshed/);
257
+ expect(failed.events()).toEqual(['token.mint-failed']);
258
+ expect(failed.emitted[0].method).toBe('error');
259
+ });
260
+
261
+ it('gives different credentials different tags', async () => {
262
+ vi.stubGlobal('fetch', vi.fn(async () => tokenResponse(ACCESS_1)));
263
+ const log = captureLog();
264
+ const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
265
+ const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
266
+ await alice();
267
+ await bob();
268
+ const [a, b] = log.records().map((r) => r.credential);
269
+ expect(a).not.toBe(b);
270
+ });
271
+
272
+ it('leaves a source that can mint nothing without a credential tag to promise', () => {
273
+ // A directly-supplied GOG_ACCESS_TOKEN has no refresh credential behind it,
274
+ // and it emits no records — so there is nothing for a tag to correlate.
275
+ const direct = makeAccessTokenSource({ GOG_ACCESS_TOKEN: ACCESS_1 })!;
276
+ expect(direct.credentialId).toBeUndefined();
277
+ });
278
+ });
279
+
280
+ describe('connector-runtime records the transitions the runner path authors', () => {
281
+ const ENDPOINT = 'https://gogcli-gog-runner.fly.dev';
282
+ const KEY = 'k';
283
+ const GOOGLE_401_STDERR =
284
+ 'Google API error (401 authError): Request had invalid authentication credentials.';
285
+ const READ = ['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'];
286
+ const WRITE = ['--json', '--color=never', '--no-input', 'gmail', 'send', '--to', 'a@b.c'];
287
+
288
+ function gogFailed(stderr: string) {
289
+ return { ok: false, status: 422, json: async () => ({ error: `Command failed\n${stderr}`, stderr }) };
290
+ }
291
+ const ok = (stdout: string) => ({ ok: true, json: async () => ({ stdout }) });
292
+ function source(tokens: (string | undefined)[], evicted = true) {
293
+ return Object.assign(vi.fn(async () => tokens.shift()), {
294
+ invalidate: vi.fn(async () => evicted),
295
+ credentialId: async () => 'cafebabe0001',
296
+ });
297
+ }
298
+
299
+ it('records the runner bearer rejection as a TRANSPORT failure, naming no credential', async () => {
300
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) })));
301
+ const log = captureLog();
302
+ await expect(makeFlyExecutor(ENDPOINT, KEY, source(['ya29.x']))(READ, {})).rejects.toThrow();
303
+
304
+ expect(log.events()).toEqual(['runner.auth-failed']);
305
+ const [record] = log.records();
306
+ expect(record.endpoint).toBe(ENDPOINT);
307
+ expect(record.service).toBe('gmail');
308
+ expect(record.credential).toBeUndefined();
309
+ // The record must not re-create defect 1 for a human reading the log.
310
+ expect(record.reason).toMatch(/never ran|no Google credential/i);
311
+ });
312
+
313
+ it('records the replay it attempts and the replay that works', async () => {
314
+ vi.stubGlobal(
315
+ 'fetch',
316
+ vi.fn().mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR)).mockResolvedValueOnce(ok('threads')),
317
+ );
318
+ const log = captureLog();
319
+ await expect(makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale', 'ya29.fresh']))(READ, {})).resolves.toBe(
320
+ 'threads',
321
+ );
322
+ expect(log.events()).toEqual(['replay.attempted', 'replay.succeeded']);
323
+ expect(log.records()[0].credential).toBe('cafebabe0001');
324
+ expect(log.records()[0].service).toBe('gmail');
325
+ });
326
+
327
+ it('records a replay that still failed, so a genuinely dead credential is visible', async () => {
328
+ vi.stubGlobal('fetch', vi.fn(async () => gogFailed(GOOGLE_401_STDERR)));
329
+ const log = captureLog();
330
+ await expect(
331
+ makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale', 'ya29.fresh']))(READ, {}),
332
+ ).rejects.toThrow(/401/);
333
+ expect(log.events()).toEqual(['replay.attempted', 'replay.failed']);
334
+ expect(log.emitted[1].method).toBe('error');
335
+ });
336
+
337
+ it('records WHY a replay was declined, one machine-readable reason each', async () => {
338
+ const cases: [string, () => Promise<unknown>, RegExp][] = [
339
+ [
340
+ 'invalid_grant',
341
+ () => makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale']))(READ, {}),
342
+ /re-authoriz/i,
343
+ ],
344
+ ['no token', () => makeFlyExecutor(ENDPOINT, KEY)(READ, {}), /no access token/i],
345
+ [
346
+ 'cannot re-mint',
347
+ () => makeFlyExecutor(ENDPOINT, KEY, () => 'ya29.direct')(READ, {}),
348
+ /cannot mint/i,
349
+ ],
350
+ [
351
+ // The same refusal reached through the source the PRODUCTION direct
352
+ // config actually builds. It used to fall past this rule and be
353
+ // recorded as "already superseded" — blaming a concurrent caller for
354
+ // the one config where re-authorizing really is the repair.
355
+ 'real GOG_ACCESS_TOKEN source',
356
+ () =>
357
+ makeFlyExecutor(
358
+ ENDPOINT,
359
+ KEY,
360
+ makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' }),
361
+ )(READ, {}),
362
+ /cannot mint/i,
363
+ ],
364
+ [
365
+ 'write',
366
+ () => makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale']))(WRITE, {}),
367
+ /write/i,
368
+ ],
369
+ [
370
+ 'superseded',
371
+ () => makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale'], false))(READ, {}),
372
+ /superseded|already replaced/i,
373
+ ],
374
+ [
375
+ 'nothing minted',
376
+ () => makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale', undefined]))(READ, {}),
377
+ /no token/i,
378
+ ],
379
+ ];
380
+
381
+ for (const [name, invoke, expected] of cases) {
382
+ vi.restoreAllMocks();
383
+ const stderr = name === 'invalid_grant' ? `${GOOGLE_401_STDERR}\noauth2: "invalid_grant"` : GOOGLE_401_STDERR;
384
+ vi.stubGlobal('fetch', vi.fn(async () => gogFailed(stderr)));
385
+ const log = captureLog();
386
+ await expect(invoke()).rejects.toThrow();
387
+ // The 'no token' case — the HOSTED shape — now writes a second record
388
+ // BEFORE its decision: the live reading of the Google layer this branch
389
+ // added, which here reports `refusal.google-unmeasured` because the stub
390
+ // answers /health/google with the same non-2xx it answers /run with. The
391
+ // decision is still the LAST word in every case, which is what this test
392
+ // is about.
393
+ const records = log.records();
394
+ expect(records.length, name).toBe(name === 'no token' ? 2 : 1);
395
+ if (name === 'no token') expect(records[0].event).toBe('refusal.google-unmeasured');
396
+ const record = records[records.length - 1];
397
+ expect(record.event, name).toBe(name === 'invalid_grant' ? 'grant.dead' : 'replay.declined');
398
+ expect(record.reason as string, name).toMatch(expected);
399
+ }
400
+ });
401
+
402
+ it('stays silent for a gog failure that has nothing to do with auth', async () => {
403
+ vi.stubGlobal('fetch', vi.fn(async () => gogFailed('invalid attachment id')));
404
+ const log = captureLog();
405
+ await expect(
406
+ makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale']))(READ, {}),
407
+ ).rejects.toThrow(/invalid attachment/);
408
+ // An auth log that also carries every bad-message-id is an auth log nobody
409
+ // reads. Only failures a credential could explain are recorded.
410
+ expect(log.emitted).toEqual([]);
411
+ });
412
+
413
+ it('records a service even when the invocation carries no subcommand to judge', async () => {
414
+ vi.stubGlobal('fetch', vi.fn(async () => gogFailed(GOOGLE_401_STDERR)));
415
+ const log = captureLog();
416
+ await expect(
417
+ makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale']))(['--json', 'gmail'], {}),
418
+ ).rejects.toThrow();
419
+ expect(log.records()[0].service).toBe('gmail');
420
+ });
421
+ });
422
+
423
+ describe('the hard requirement: credential material cannot reach a log line', () => {
424
+ it('survives a full mint → reject → evict → re-mint → replay lifecycle without leaking', async () => {
425
+ // The real token source, the real executor, the real error text — including
426
+ // a gog stderr that quotes the access token verbatim, which is the way a
427
+ // token realistically gets near a log at all.
428
+ let minted = 0;
429
+ const runResponses = [
430
+ {
431
+ ok: false,
432
+ status: 422,
433
+ json: async () => ({
434
+ error: `Command failed: gog gmail search q --token ${ACCESS_1}`,
435
+ stderr: `Google API error (401 authError): token ${ACCESS_1} had invalid authentication credentials.`,
436
+ }),
437
+ },
438
+ { ok: true, json: async () => ({ stdout: '{"threads":[]}' }) },
439
+ ];
440
+ vi.stubGlobal(
441
+ 'fetch',
442
+ vi.fn(async (url: unknown) => {
443
+ if (String(url).includes('oauth2.googleapis.com')) {
444
+ return tokenResponse((minted += 1) === 1 ? ACCESS_1 : ACCESS_2);
445
+ }
446
+ return runResponses.shift();
447
+ }),
448
+ );
449
+
450
+ const log = captureLog();
451
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
452
+ const exec = makeFlyExecutor('https://gogcli-gog-runner.fly.dev', 'runner-key', source);
453
+ await expect(exec(['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'], {})).resolves.toBe(
454
+ '{"threads":[]}',
455
+ );
456
+
457
+ // The whole story got recorded...
458
+ expect(log.events()).toEqual([
459
+ 'token.minted',
460
+ 'token.evicted',
461
+ 'token.minted',
462
+ 'replay.attempted',
463
+ 'replay.succeeded',
464
+ ]);
465
+ // ...and not one byte of credential material is in any of it.
466
+ const everything = log.allText();
467
+ for (const secret of CREDENTIAL_MATERIAL) {
468
+ expect(everything, secret).not.toContain(secret);
469
+ }
470
+ // Nor the runner's own bearer, which is a credential too.
471
+ expect(everything).not.toContain('runner-key');
472
+ expect(log.toStdout).toEqual([]);
473
+ });
474
+
475
+ it('cannot leak a token that a failure message quotes verbatim', async () => {
476
+ // The realistic way a token gets near a log at all: the replay fails too,
477
+ // and the record of that failure is built from gog's own error text, which
478
+ // embeds the whole command line — access token included. Nothing in this
479
+ // layer authored that string, so nothing in this layer can vet it; the only
480
+ // thing standing between it and the log is the redactor.
481
+ let minted = 0;
482
+ vi.stubGlobal(
483
+ 'fetch',
484
+ vi.fn(async (url: unknown) => {
485
+ if (String(url).includes('oauth2.googleapis.com')) {
486
+ return tokenResponse((minted += 1) === 1 ? ACCESS_1 : ACCESS_2);
487
+ }
488
+ return {
489
+ ok: false,
490
+ status: 422,
491
+ json: async () => ({
492
+ error: `Command failed: gog gmail search q --access-token ${minted === 1 ? ACCESS_1 : ACCESS_2}`,
493
+ stderr:
494
+ `Google API error (401 authError): token ${minted === 1 ? ACCESS_1 : ACCESS_2} ` +
495
+ 'had invalid authentication credentials.',
496
+ }),
497
+ };
498
+ }),
499
+ );
500
+
501
+ const log = captureLog();
502
+ const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: REFRESH })!;
503
+ const exec = makeFlyExecutor('https://gogcli-gog-runner.fly.dev', 'runner-key', source);
504
+ await expect(
505
+ exec(['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'], {}),
506
+ ).rejects.toThrow(/invalid authentication credentials/);
507
+
508
+ expect(log.events()).toEqual([
509
+ 'token.minted',
510
+ 'token.evicted',
511
+ 'token.minted',
512
+ 'replay.attempted',
513
+ 'replay.failed',
514
+ // The replay's own token was refused too, so it leaves the cache exactly
515
+ // as the first one did. Without this trailing eviction the cache keeps
516
+ // serving a token that has now failed twice, and every following call
517
+ // pays a mint and two /run round-trips to rediscover that.
518
+ 'token.evicted',
519
+ ]);
520
+ // The failure record really did carry the offending text through...
521
+ const failed = log.records().at(-2)!;
522
+ expect(failed.reason as string).toMatch(/invalid authentication credentials/);
523
+ // ...with the token itself replaced.
524
+ expect(failed.reason as string).toContain('[REDACTED]');
525
+ const everything = log.allText();
526
+ for (const secret of CREDENTIAL_MATERIAL) {
527
+ expect(everything, secret).not.toContain(secret);
528
+ }
529
+ });
530
+ });