gogcli-mcp 3.0.0 → 4.0.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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +474 -947
  4. package/dist/lib.js +575 -983
  5. package/manifest.json +2 -2
  6. package/mint.yaml +41 -37
  7. package/package.json +3 -3
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/index.ts +3 -4
  14. package/src/lib.ts +14 -9
  15. package/src/runner.ts +27 -176
  16. package/src/tools/appscript.ts +1 -1
  17. package/src/tools/auth.ts +5 -5
  18. package/src/tools/drive.ts +1 -3
  19. package/src/tools/gmail.ts +9 -9
  20. package/src/tools/utils.ts +12 -58
  21. package/tests/attachments.test.ts +11 -14
  22. package/tests/blob-upload.test.ts +235 -160
  23. package/tests/bootstrap-auth.test.ts +245 -0
  24. package/tests/runner-file-args.test.ts +1 -13
  25. package/tests/runner.test.ts +53 -96
  26. package/tests/sdk-single-copy.test.ts +4 -12
  27. package/tests/tools/auth-401-shapes.test.ts +2 -3
  28. package/tests/tools/auth.test.ts +5 -4
  29. package/tests/tools/drive.test.ts +11 -3
  30. package/tests/tools/gmail.test.ts +2 -2
  31. package/tests/tools/utils.test.ts +1 -50
  32. package/tests/zod-single-copy.test.ts +6 -14
  33. package/tsconfig.json +1 -2
  34. package/vitest.config.ts +2 -12
  35. package/src/auth-log.ts +0 -205
  36. package/src/connector-auth.ts +0 -319
  37. package/src/connector-login.ts +0 -87
  38. package/src/connector-runtime.ts +0 -910
  39. package/src/google-probe.ts +0 -113
  40. package/src/google-token.ts +0 -391
  41. package/src/remote-runner.ts +0 -77
  42. package/src/worker.ts +0 -117
  43. package/tests/auth-log.test.ts +0 -530
  44. package/tests/connector-auth.test.ts +0 -559
  45. package/tests/connector-login.test.ts +0 -151
  46. package/tests/connector-runtime.test.ts +0 -1664
  47. package/tests/google-probe.test.ts +0 -116
  48. package/tests/google-token.test.ts +0 -425
  49. package/tests/remote-runner.test.ts +0 -202
  50. package/tests/worker.test.ts +0 -167
package/src/worker.ts DELETED
@@ -1,117 +0,0 @@
1
- import { McpServer } from '@modelcontextprotocol/server';
2
- import { createMcpHandler, getMcpAuthContext } from 'agents/mcp/server';
3
- import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
4
- import type { ToolRegistrar } from '@chrischall/mcp-utils';
5
- import {
6
- BASE_TOOL_REGISTRARS,
7
- authToolsFor,
8
- registerSheetsTools,
9
- registerGmailTools,
10
- registerDriveTools,
11
- registerDocsTools,
12
- } from './lib.js';
13
- import { registerExtraSheetsTools } from '../../gogcli-mcp-sheets/src/tools/sheets-extra.js';
14
- import { registerExtraGmailTools } from '../../gogcli-mcp-gmail/src/tools/gmail-extra.js';
15
- import { registerExtraDriveTools } from '../../gogcli-mcp-drive/src/tools/drive-extra.js';
16
- import { registerExtraDocsTools } from '../../gogcli-mcp-docs/src/tools/docs-extra.js';
17
- import { createFlyExecutorResolver, wrapServer } from './connector-runtime.js';
18
- import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth.js';
19
- import { handleAuthorize } from './connector-login.js';
20
-
21
- // The Cloudflare remote-connector entrypoint for gogcli-mcp.
22
- //
23
- // It reuses the EXISTING transport-neutral tool registrars UNCHANGED and executes
24
- // every assembled `gog` arg-array by forwarding it to a Fly.io backend (a Worker
25
- // cannot spawn processes). The bridge is the `runExecutor` AsyncLocalStorage seam
26
- // in `runner.ts`: `wrapServer` scopes each tool handler in `runExecutor.run(...)`
27
- // so the handler's `run()` forwards to the authenticated caller's Fly executor.
28
- //
29
- // One stateless Worker serves several MCP endpoints under one OAuth login:
30
- // /mcp all-services base (BASE_TOOL_REGISTRARS)
31
- // /mcp/sheets auth + Sheets base + Sheets extras
32
- // /mcp/gmail auth + Gmail base + Gmail extras
33
- // /mcp/drive auth + Drive base + Drive extras
34
- // /mcp/docs auth + Docs base + Docs extras
35
- // Each per-service path exposes the SAME tool set as that sub-package's stdio
36
- // server, so the ~50-70 extras per service are reachable without swamping one
37
- // connector with all ~360 tools at once. Add whichever paths you want as separate
38
- // connectors in claude.ai (each authorizes with the same connector key).
39
-
40
- const VERSION = '3.0.0'; // x-release-please-version
41
-
42
- type WorkerEnv = { FLY_ENDPOINT: string };
43
-
44
- // One resolver per Worker isolate. Reusing an executor for the same backend
45
- // credential preserves makeFlyExecutor's refusal-probe throttle across
46
- // stateless requests; different credentials retain independent throttles.
47
- const resolveFlyExecutor = createFlyExecutorResolver();
48
-
49
- // Build a fresh SDK v2 server for every HTTP request. That is the transport
50
- // model required by MCP 2026-07-28 multi round-trip requests: input_required
51
- // returns to the client, and the later request reconstructs the server from
52
- // the tool arguments plus protocol-managed inputResponses.
53
- function makeHandler(route: string, registrars: ToolRegistrar[]) {
54
- return {
55
- fetch(request: Request, env: WorkerEnv, ctx: ExecutionContext) {
56
- const handler = createMcpHandler(() => {
57
- // Resolve the OAuth props lazily while the tool runs. OAuthProvider
58
- // establishes this request context before calling the MCP handler.
59
- const executor = ((args, options) => {
60
- const props = getMcpAuthContext()?.props as GogProps | undefined;
61
- if (!props?.key) throw new Error('Missing authenticated gogcli connector key');
62
- return resolveFlyExecutor(env.FLY_ENDPOINT, props.key)(args, options);
63
- }) satisfies ReturnType<typeof resolveFlyExecutor>;
64
-
65
- // `instructions` is the connector's only channel to the model that is
66
- // not a tool description. It explains that connector authentication
67
- // does not prove the Google credential itself is healthy.
68
- const server = new McpServer(
69
- { name: 'gogcli-mcp', version: VERSION },
70
- { instructions: CONNECTOR_INSTRUCTIONS },
71
- );
72
- const wrapped = wrapServer(server, executor);
73
- for (const register of registrars) register(wrapped);
74
- return server;
75
- }, {
76
- route,
77
- allowedHostnames: ['connector.gogcli.nullnet.app'],
78
- });
79
- return handler(request, env, ctx);
80
- },
81
- };
82
- }
83
-
84
- // auth + <service> base + <service> extras — the exact set each sub-package's
85
- // stdio server exposes. The auth registrar is bound to THIS service's default
86
- // `services` (least-privilege): a per-service connector re-auths requesting only
87
- // its own scopes, so one unregistered scope for a service it doesn't wrap can't
88
- // poison its re-auth with invalid_scope. The base /mcp agent keeps 'all'.
89
- const svc = (service: string, base: ToolRegistrar, extra: ToolRegistrar): ToolRegistrar[] =>
90
- [authToolsFor(service), base, extra];
91
-
92
- const defaultHandler = {
93
- fetch(request: Request, env: unknown): Response | Promise<Response> {
94
- const url = new URL(request.url);
95
- if (url.pathname === '/authorize') return handleAuthorize(request, env, gogAuth);
96
- return new Response('Not found', { status: 404 });
97
- },
98
- };
99
-
100
- // NOTE: OAuthProvider matches apiHandlers by PREFIX and returns the FIRST match,
101
- // so the specific per-service paths MUST be listed before the base `/mcp`
102
- // (otherwise `/mcp` greedily swallows `/mcp/sheets`).
103
- const handler = new OAuthProvider({
104
- apiHandlers: {
105
- '/mcp/sheets': makeHandler('/mcp/sheets', svc('sheets', registerSheetsTools, registerExtraSheetsTools)) as never,
106
- '/mcp/gmail': makeHandler('/mcp/gmail', svc('gmail', registerGmailTools, registerExtraGmailTools)) as never,
107
- '/mcp/drive': makeHandler('/mcp/drive', svc('drive,driveactivity,drivelabels', registerDriveTools, registerExtraDriveTools)) as never,
108
- '/mcp/docs': makeHandler('/mcp/docs', svc('docs', registerDocsTools, registerExtraDocsTools)) as never,
109
- '/mcp': makeHandler('/mcp', BASE_TOOL_REGISTRARS) as never,
110
- },
111
- defaultHandler: defaultHandler as never,
112
- authorizeEndpoint: '/authorize',
113
- tokenEndpoint: '/token',
114
- clientRegistrationEndpoint: '/register',
115
- });
116
-
117
- export default handler;
@@ -1,530 +0,0 @@
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
- });