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
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { bootstrapGogAuth, AUTH_BOOTSTRAP_MARKER } from '../src/bootstrap-auth.js';
8
+ import type { Spawner } from '../src/runner.js';
9
+
10
+ const FULL = {
11
+ GOG_CLIENT_ID: 'client-id.apps.googleusercontent.com',
12
+ GOG_CLIENT_SECRET: 'GOCSPX-client-secret-value',
13
+ GOG_REFRESH_TOKEN: '1//refresh-token-value',
14
+ GOG_ACCOUNT: 'someone@example.com',
15
+ };
16
+
17
+ function fingerprint(env: typeof FULL): string {
18
+ return createHash('sha256')
19
+ .update([env.GOG_CLIENT_ID, env.GOG_CLIENT_SECRET, env.GOG_REFRESH_TOKEN, env.GOG_ACCOUNT].join('\0'))
20
+ .digest('hex');
21
+ }
22
+
23
+ interface Call {
24
+ argv: string[];
25
+ env: NodeJS.ProcessEnv;
26
+ /** Contents of every temp-file path in argv, read while the child "runs". */
27
+ files: string[];
28
+ }
29
+
30
+ type Reply = { code: number; stdout?: string; stderr?: string } | { error: Error };
31
+
32
+ function makeSpawner(reply: (argv: string[]) => Reply): { spawner: Spawner; calls: Call[] } {
33
+ const calls: Call[] = [];
34
+ const spawner = vi.fn((_cmd: string, argv: string[], opts: { env: NodeJS.ProcessEnv }) => {
35
+ const files = argv.filter((a) => a.includes('gogcli-mcp-') && existsSync(a)).map((p) => readFileSync(p, 'utf8'));
36
+ calls.push({ argv, env: opts.env, files });
37
+ const proc = new EventEmitter() as ReturnType<Spawner>;
38
+ const io = proc as unknown as { stdout: EventEmitter; stderr: EventEmitter };
39
+ io.stdout = new EventEmitter();
40
+ io.stderr = new EventEmitter();
41
+ proc.kill = vi.fn();
42
+ const r = reply(argv);
43
+ setTimeout(() => {
44
+ if ('error' in r) {
45
+ proc.emit('error', r.error);
46
+ return;
47
+ }
48
+ if (r.stdout) io.stdout.emit('data', Buffer.from(r.stdout));
49
+ if (r.stderr) io.stderr.emit('data', Buffer.from(r.stderr));
50
+ proc.emit('close', r.code);
51
+ }, 0);
52
+ return proc;
53
+ }) as unknown as Spawner;
54
+ return { spawner, calls };
55
+ }
56
+
57
+ const listing = (...emails: string[]): string => JSON.stringify({ accounts: emails.map((email) => ({ email })) });
58
+
59
+ const isList = (argv: string[]): boolean => argv.includes('list');
60
+
61
+ describe('bootstrapGogAuth', () => {
62
+ let home: string;
63
+ let stderr: ReturnType<typeof vi.spyOn>;
64
+
65
+ beforeEach(() => {
66
+ home = mkdtempSync(join(tmpdir(), 'bootstrap-auth-test-'));
67
+ stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
68
+ });
69
+
70
+ afterEach(() => {
71
+ stderr.mockRestore();
72
+ rmSync(home, { recursive: true, force: true });
73
+ });
74
+
75
+ const stderrText = (): string => stderr.mock.calls.map((c: unknown[]) => String(c[0])).join('');
76
+
77
+ it('does nothing when none of the variables is set', async () => {
78
+ const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
79
+ const status = await bootstrapGogAuth({}, { spawner, home });
80
+ expect(status).toBe('unconfigured');
81
+ expect(calls).toHaveLength(0);
82
+ expect(stderr).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('treats blank and unresolved-placeholder values as unset', async () => {
86
+ const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
87
+ const status = await bootstrapGogAuth(
88
+ { GOG_CLIENT_ID: '', GOG_CLIENT_SECRET: '${user_config.secret}', GOG_REFRESH_TOKEN: 'undefined', GOG_ACCOUNT: ' ' },
89
+ { spawner, home },
90
+ );
91
+ expect(status).toBe('unconfigured');
92
+ expect(calls).toHaveLength(0);
93
+ });
94
+
95
+ it('names the missing variables, and never a value, when only some are set', async () => {
96
+ const { spawner, calls } = makeSpawner(() => ({ code: 0 }));
97
+ const status = await bootstrapGogAuth(
98
+ { GOG_CLIENT_ID: FULL.GOG_CLIENT_ID, GOG_REFRESH_TOKEN: FULL.GOG_REFRESH_TOKEN },
99
+ { spawner, home },
100
+ );
101
+ expect(status).toBe('incomplete');
102
+ expect(calls).toHaveLength(0);
103
+ const out = stderrText();
104
+ expect(out).toContain('GOG_CLIENT_SECRET');
105
+ expect(out).toContain('GOG_ACCOUNT');
106
+ expect(out).not.toContain(FULL.GOG_CLIENT_ID);
107
+ expect(out).not.toContain(FULL.GOG_REFRESH_TOKEN);
108
+ expect(stderr).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it('imports credentials then the token, only via temp files, and writes a private marker', async () => {
112
+ const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
113
+ const status = await bootstrapGogAuth({ ...FULL }, { spawner, home });
114
+ expect(status).toBe('imported');
115
+ expect(calls).toHaveLength(2);
116
+
117
+ const [creds, tokens] = calls;
118
+ expect(creds.argv).toEqual(expect.arrayContaining(['auth', 'credentials', 'set']));
119
+ expect(JSON.parse(creds.files[0])).toEqual({
120
+ installed: { client_id: FULL.GOG_CLIENT_ID, client_secret: FULL.GOG_CLIENT_SECRET },
121
+ });
122
+ expect(tokens.argv).toEqual(expect.arrayContaining(['auth', 'tokens', 'import']));
123
+ expect(JSON.parse(tokens.files[0])).toEqual({ email: FULL.GOG_ACCOUNT, refresh_token: FULL.GOG_REFRESH_TOKEN });
124
+
125
+ // The temp file is the LAST argv element: the positional <path> gog reads.
126
+ for (const call of calls) {
127
+ expect(call.files).toHaveLength(1);
128
+ expect(existsSync(call.argv[call.argv.length - 1])).toBe(false); // cleaned up afterwards
129
+ const argvText = call.argv.join(' ');
130
+ expect(argvText).not.toContain(FULL.GOG_CLIENT_SECRET);
131
+ expect(argvText).not.toContain(FULL.GOG_REFRESH_TOKEN);
132
+ const envText = JSON.stringify(call.env);
133
+ expect(envText).not.toContain(FULL.GOG_CLIENT_SECRET);
134
+ expect(envText).not.toContain(FULL.GOG_REFRESH_TOKEN);
135
+ }
136
+
137
+ const markerDir = join(home, '.gogcli-mcp');
138
+ const marker = join(markerDir, AUTH_BOOTSTRAP_MARKER);
139
+ expect(readFileSync(marker, 'utf8')).toBe(fingerprint(FULL));
140
+ expect(statSync(markerDir).mode & 0o777).toBe(0o700);
141
+ expect(statSync(marker).mode & 0o777).toBe(0o600);
142
+ });
143
+
144
+ it('reports present and does nothing when the marker matches and gog lists the account', async () => {
145
+ mkdirSync(join(home, '.gogcli-mcp'));
146
+ writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
147
+ const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: listing('other@example.com', 'SomeOne@Example.com') }));
148
+ const status = await bootstrapGogAuth({ ...FULL }, { spawner, home });
149
+ expect(status).toBe('present');
150
+ expect(calls).toHaveLength(1);
151
+ expect(calls[0].argv).toEqual(expect.arrayContaining(['auth', 'list']));
152
+ });
153
+
154
+ it('re-imports when the marker matches but gog no longer lists the account', async () => {
155
+ mkdirSync(join(home, '.gogcli-mcp'));
156
+ writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
157
+ const { spawner, calls } = makeSpawner((argv) => ({ code: 0, stdout: isList(argv) ? listing('other@example.com') : '{}' }));
158
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
159
+ expect(calls.map((c) => c.argv.includes('list'))).toEqual([true, false, false]);
160
+ });
161
+
162
+ it.each([
163
+ ['an unparseable listing', { code: 0, stdout: 'not json' }],
164
+ ['a listing without accounts', { code: 0, stdout: '{"accounts":null}' }],
165
+ ['a listing whose entries carry no email', { code: 0, stdout: '{"accounts":[{}]}' }],
166
+ ['a failing listing', { code: 1, stderr: 'keyring locked' }],
167
+ ])('re-imports when the marker matches but gog returns %s', async (_label, listReply) => {
168
+ mkdirSync(join(home, '.gogcli-mcp'));
169
+ writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), fingerprint(FULL));
170
+ const { spawner, calls } = makeSpawner((argv) => (isList(argv) ? listReply : { code: 0, stdout: '{}' }));
171
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
172
+ expect(calls).toHaveLength(3);
173
+ });
174
+
175
+ it('re-imports without listing when the secret was rotated (marker differs)', async () => {
176
+ mkdirSync(join(home, '.gogcli-mcp'));
177
+ writeFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'stale-fingerprint');
178
+ const { spawner, calls } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
179
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('imported');
180
+ expect(calls).toHaveLength(2);
181
+ expect(calls.some((c) => c.argv.includes('list'))).toBe(false);
182
+ expect(readFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'utf8')).toBe(fingerprint(FULL));
183
+ });
184
+
185
+ it('fails without throwing, redacts, and writes no marker when gog rejects the import', async () => {
186
+ const { spawner } = makeSpawner((argv) =>
187
+ argv.includes('tokens')
188
+ ? { code: 1, stderr: `bad token ${FULL.GOG_REFRESH_TOKEN} for secret ${FULL.GOG_CLIENT_SECRET}` }
189
+ : { code: 0, stdout: '{}' },
190
+ );
191
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
192
+ const out = stderrText();
193
+ expect(stderr).toHaveBeenCalledTimes(1);
194
+ expect(out).toContain('bad token');
195
+ expect(out).not.toContain(FULL.GOG_REFRESH_TOKEN);
196
+ expect(out).not.toContain(FULL.GOG_CLIENT_SECRET);
197
+ expect(existsSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER))).toBe(false);
198
+ });
199
+
200
+ it('fails without throwing when gog is not installed', async () => {
201
+ const enoent = Object.assign(new Error('spawn gog ENOENT'), { code: 'ENOENT' });
202
+ const { spawner } = makeSpawner(() => ({ error: enoent }));
203
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
204
+ expect(stderrText()).toContain('gog executable not found');
205
+ });
206
+
207
+ it('fails without throwing when the marker cannot be written', async () => {
208
+ writeFileSync(join(home, '.gogcli-mcp'), 'a file where the directory should be');
209
+ const { spawner } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
210
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner, home })).toBe('failed');
211
+ expect(stderr).toHaveBeenCalledTimes(1);
212
+ });
213
+
214
+ it('stringifies a non-Error failure', async () => {
215
+ const throwing = vi.fn(() => {
216
+ throw 'plain string failure';
217
+ }) as unknown as Spawner;
218
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner: throwing, home })).toBe('failed');
219
+ expect(stderrText()).toContain('plain string failure');
220
+ });
221
+
222
+ it('puts the marker under the OS home directory by default', async () => {
223
+ const savedHome = process.env.HOME;
224
+ process.env.HOME = home;
225
+ try {
226
+ const { spawner } = makeSpawner(() => ({ code: 0, stdout: '{}' }));
227
+ expect(await bootstrapGogAuth({ ...FULL }, { spawner })).toBe('imported');
228
+ expect(readFileSync(join(home, '.gogcli-mcp', AUTH_BOOTSTRAP_MARKER), 'utf8')).toBe(fingerprint(FULL));
229
+ } finally {
230
+ process.env.HOME = savedHome;
231
+ }
232
+ });
233
+
234
+ it('defaults to process.env', async () => {
235
+ // With none of the four variables set the default path is a no-op, so this
236
+ // proves the defaults are wired without touching the real home directory.
237
+ const saved = Object.fromEntries(Object.keys(FULL).map((k) => [k, process.env[k]]));
238
+ for (const k of Object.keys(FULL)) delete process.env[k];
239
+ try {
240
+ expect(await bootstrapGogAuth()).toBe('unconfigured');
241
+ } finally {
242
+ for (const [k, v] of Object.entries(saved)) if (v !== undefined) process.env[k] = v;
243
+ }
244
+ });
245
+ });
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
5
  import { run, isGogFileArg } from '../src/runner.js';
6
- import type { Spawner, GogArg } from '../src/runner.js';
6
+ import type { Spawner } from '../src/runner.js';
7
7
  import { payloadArg, PAYLOAD_INLINE_MAX } from '../src/tools/utils.js';
8
8
 
9
9
  // These tests deliberately use the REAL fs: the whole point is that the bytes
@@ -210,18 +210,6 @@ describe('run with GogFileArgs', () => {
210
210
  expect(capture.dirs).toHaveLength(1);
211
211
  expect(existsSync(capture.dirs[0])).toBe(false);
212
212
  });
213
-
214
- it('forwards GogFileArgs unmaterialized to an injected executor', async () => {
215
- // The hosted (Worker/Fly) executor does its own materialization on the
216
- // remote side, so run() must hand it the union, not a local path.
217
- const { runExecutor } = await import('../src/runner.js');
218
- let seen: GogArg[] = [];
219
- const executor = vi.fn(async (args: GogArg[]) => { seen = args; return '{}'; });
220
- await runExecutor.run({ executor }, () =>
221
- run(['gmail', 'send', payloadArg('body', 'body-file', big(PAYLOAD_INLINE_MAX + 1))], {}),
222
- );
223
- expect(seen.at(-1)).toMatchObject({ kind: 'file', flag: 'body-file' });
224
- });
225
213
  });
226
214
 
227
215
  describe('isGogFileArg', () => {
@@ -1,8 +1,8 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { spawn as mockedSpawn } from 'node:child_process';
4
- import { run, runBinary, runExecutor, RunnerTransportError, isRunnerTransportError } from '../src/runner.js';
5
- import type { Spawner, GogExecutor } from '../src/runner.js';
4
+ import { run, runBinary } from '../src/runner.js';
5
+ import type { Spawner } from '../src/runner.js';
6
6
 
7
7
  // The real spawn is dynamically imported inside runner's default executor.
8
8
  // Mock it so the no-spawner/no-executor fallback can be exercised without
@@ -533,28 +533,23 @@ describe('run', () => {
533
533
  });
534
534
 
535
535
  // The suffix list was `_TOKEN|_SECRET|_API_KEY|_PRIVATE_KEY` — four spellings
536
- // of "a key", none of which is a bare `_KEY`. Two credentials this repo hands
537
- // its own process sit in exactly that gap: `MCP_BLOB_SIGNING_KEY` (mints the
538
- // signed blob URLs a `deliver="url"` download is uploaded to) and
539
- // `GOG_RUNNER_KEY` (the bearer for the Fly backend, and for `POST /upload`,
540
- // which is arbitrary-gog-argv on that box). Neither is anything `gog` reads.
541
- it('strips a bare *_KEY var — the host signing key and the runner bearer', async () => {
536
+ // of "a key", none of which is a bare `_KEY`. `MCP_BLOB_SIGNING_KEY` (mints the
537
+ // signed blob URLs a `deliver="url"` download is uploaded to) sat in exactly
538
+ // that gap, and it is nothing `gog` reads.
539
+ it('strips a bare *_KEY var — the host blob signing key', async () => {
542
540
  const spawner = makeSpawner(0, '{}');
543
541
  const snapshot = {
544
542
  MCP_BLOB_SIGNING_KEY: process.env.MCP_BLOB_SIGNING_KEY,
545
- GOG_RUNNER_KEY: process.env.GOG_RUNNER_KEY,
546
543
  STRIPE_KEY: process.env.STRIPE_KEY,
547
544
  AWS_CREDENTIALS: process.env.AWS_CREDENTIALS,
548
545
  };
549
546
  process.env.MCP_BLOB_SIGNING_KEY = 'blob-signing-secret';
550
- process.env.GOG_RUNNER_KEY = 'runner-bearer-secret';
551
547
  process.env.STRIPE_KEY = 'sk-live-secret';
552
548
  process.env.AWS_CREDENTIALS = '/path/to/creds';
553
549
  try {
554
550
  await run(['docs', 'cat', 'id'], { spawner });
555
551
  const envPassed = (spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv;
556
552
  expect(envPassed.MCP_BLOB_SIGNING_KEY).toBeUndefined();
557
- expect(envPassed.GOG_RUNNER_KEY).toBeUndefined();
558
553
  expect(envPassed.STRIPE_KEY).toBeUndefined();
559
554
  // `_CREDENTIALS`, generalising the one named GOOGLE_APPLICATION_CREDENTIALS.
560
555
  expect(envPassed.AWS_CREDENTIALS).toBeUndefined();
@@ -593,6 +588,50 @@ describe('run', () => {
593
588
  }
594
589
  });
595
590
 
591
+ // gog formats naive dates in GOG_TIMEZONE, else the process's local zone; the
592
+ // wrapper re-attaches offsets assuming GOG_TIMEZONE, else DISPLAY_TZ. Unless
593
+ // gog is TOLD the zone the wrapper assumes, a UTC host shifts every date.
594
+ describe('pins gog to the zone the wrapper reads its naive dates in', () => {
595
+ const keys = ['GOG_TIMEZONE', 'DISPLAY_TZ'] as const;
596
+ let snapshot: Record<string, string | undefined>;
597
+ beforeEach(() => { snapshot = Object.fromEntries(keys.map((k) => [k, process.env[k]])); });
598
+ afterEach(() => {
599
+ for (const [k, v] of Object.entries(snapshot)) {
600
+ if (v === undefined) delete process.env[k];
601
+ else process.env[k] = v;
602
+ }
603
+ });
604
+ const zoneGogGets = async (): Promise<string | undefined> => {
605
+ const spawner = makeSpawner(0, '{}');
606
+ await run(['gmail', 'search', 'x'], { spawner });
607
+ return ((spawner as ReturnType<typeof vi.fn>).mock.calls[0][2].env as NodeJS.ProcessEnv).GOG_TIMEZONE;
608
+ };
609
+
610
+ it('defaults gog to America/New_York when neither zone is set', async () => {
611
+ delete process.env.GOG_TIMEZONE;
612
+ delete process.env.DISPLAY_TZ;
613
+ expect(await zoneGogGets()).toBe('America/New_York');
614
+ });
615
+
616
+ it('hands gog DISPLAY_TZ when GOG_TIMEZONE is unset', async () => {
617
+ delete process.env.GOG_TIMEZONE;
618
+ process.env.DISPLAY_TZ = 'America/Los_Angeles';
619
+ expect(await zoneGogGets()).toBe('America/Los_Angeles');
620
+ });
621
+
622
+ it('passes a valid GOG_TIMEZONE through unchanged', async () => {
623
+ process.env.GOG_TIMEZONE = 'Europe/London';
624
+ process.env.DISPLAY_TZ = 'America/Los_Angeles';
625
+ expect(await zoneGogGets()).toBe('Europe/London');
626
+ });
627
+
628
+ it('replaces an invalid GOG_TIMEZONE with the zone the wrapper falls back to', async () => {
629
+ process.env.GOG_TIMEZONE = 'Not/AZone';
630
+ delete process.env.DISPLAY_TZ;
631
+ expect(await zoneGogGets()).toBe('America/New_York');
632
+ });
633
+ });
634
+
596
635
  it('redacts Bearer with quoted/encoded characters', async () => {
597
636
  const stderrLeak = 'http 401: header was Bearer eyJ.test+slash/equal=padding more text';
598
637
  const spawner = makeSpawner(1, '', stderrLeak);
@@ -803,14 +842,6 @@ describe('run', () => {
803
842
  expect(callArgs).toContain('files.get');
804
843
  });
805
844
 
806
- it('runBinary refuses over the hosted-connector forward executor', async () => {
807
- const executor = vi.fn();
808
- await expect(
809
- runExecutor.run({ executor }, () => runBinary(['api', 'call', 'drive', 'v3', 'files.get'])),
810
- ).rejects.toThrow('not available over the hosted connector');
811
- expect(executor).not.toHaveBeenCalled();
812
- });
813
-
814
845
  it('ignores timeout if close event already settled the promise', async () => {
815
846
  vi.useFakeTimers();
816
847
  const spawner = vi.fn(() => {
@@ -906,82 +937,8 @@ describe('run --readonly (gog 0.31)', () => {
906
937
  });
907
938
  });
908
939
 
909
- describe('run executor seam', () => {
910
- it('routes to an injected runExecutor executor when no options.spawner is given', async () => {
911
- const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
912
- const result = await runExecutor.run({ executor }, () => run(['sheets', 'get', 'id1', 'A1']));
913
- expect(result).toBe('{"via":"executor"}');
914
- // The executor receives the FULLY-ASSEMBLED gog arg list plus the run opts.
915
- expect(executor).toHaveBeenCalledWith(
916
- ['--json', '--color=never', '--no-input', 'sheets', 'get', 'id1', 'A1'],
917
- { timeout: undefined, interactive: false },
918
- );
919
- });
920
-
921
- it('forwards timeout and interactive through to the injected executor', async () => {
922
- const executor = vi.fn(async () => '{}') as unknown as GogExecutor;
923
- await runExecutor.run({ executor }, () =>
924
- run(['auth', 'add', 'u@g.com'], { interactive: true, timeout: 60_000 }),
925
- );
926
- expect(executor).toHaveBeenCalledWith(
927
- ['--json', '--color=never', 'auth', 'add', 'u@g.com'],
928
- { timeout: 60_000, interactive: true },
929
- );
930
- });
931
-
932
- it('redacts Google tokens returned by an injected executor', async () => {
933
- const executor = vi.fn(async () => 'token ya29.a0Ad52N3-ALS-LEAK done') as unknown as GogExecutor;
934
- const result = await runExecutor.run({ executor }, () => run(['auth', 'list']));
935
- expect(result).not.toContain('ya29.a0Ad52N3-ALS-LEAK');
936
- expect(result).toContain('[REDACTED]');
937
- });
938
-
939
- it('redacts error text thrown by an injected executor', async () => {
940
- const executor = vi.fn(async () => {
941
- throw new Error('boom 1//0eALS-REFRESH-LEAK end');
942
- }) as unknown as GogExecutor;
943
- try {
944
- await runExecutor.run({ executor }, () => run(['gmail', 'get', 'm1']));
945
- throw new Error('expected rejection');
946
- } catch (e) {
947
- const msg = (e as Error).message;
948
- expect(msg).not.toContain('1//0eALS-REFRESH-LEAK');
949
- expect(msg).toContain('[REDACTED]');
950
- }
951
- });
952
-
953
- // run() re-wraps every thrown error to redact secrets from its message. That
954
- // rewrap must not cost a RunnerTransportError its TYPE: the type is the only
955
- // thing that tells diagnose() the failure was the connector's transport and
956
- // not the caller's Google credential, and a bare Error puts it straight back
957
- // to guessing from prose.
958
- it('preserves a RunnerTransportError through the redacting rewrap', async () => {
959
- const executor = vi.fn(async () => {
960
- throw new RunnerTransportError('runner key mismatch; saw 1//0eLEAKED-REFRESH end', 'transport-auth', 401);
961
- }) as unknown as GogExecutor;
962
- const err = await runExecutor
963
- .run({ executor }, () => run(['gmail', 'get', 'm1']))
964
- .catch((e: unknown) => e);
965
- expect(isRunnerTransportError(err)).toBe(true);
966
- expect((err as RunnerTransportError).kind).toBe('transport-auth');
967
- expect((err as RunnerTransportError).status).toBe(401);
968
- // and it is still redacted
969
- expect((err as Error).message).not.toContain('1//0eLEAKED-REFRESH');
970
- expect((err as Error).message).toContain('[REDACTED]');
971
- });
972
-
973
- it('options.spawner takes precedence over an injected ALS executor', async () => {
974
- const spawner = makeSpawner(0, '{"via":"spawner"}');
975
- const executor = vi.fn(async () => '{"via":"executor"}') as unknown as GogExecutor;
976
- const result = await runExecutor.run({ executor }, () =>
977
- run(['sheets', 'get', 'id1', 'A1'], { spawner }),
978
- );
979
- expect(result).toBe('{"via":"spawner"}');
980
- expect(executor).not.toHaveBeenCalled();
981
- expect(spawner).toHaveBeenCalledOnce();
982
- });
983
-
984
- it('falls back to the lazily-imported real spawn when neither a spawner nor an ALS executor is set', async () => {
940
+ describe('run executor', () => {
941
+ it('uses the lazily-imported real spawn when no spawner is injected', async () => {
985
942
  vi.mocked(mockedSpawn).mockImplementation((() => makeProc(0, '{"real":true}')) as never);
986
943
  const result = await run(['sheets', 'get', 'id1', 'A1']);
987
944
  expect(result).toBe('{"real":true}');
@@ -3,25 +3,17 @@ import { createRequire } from 'node:module';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- // The Worker runtime still installs the legacy SDK through Cloudflare Agents,
7
- // while this project itself now uses the modular SDK v2 server.
8
- // Keep each lane on one physical copy: both server classes carry private state,
9
- // so duplicate copies produce nominal TypeScript failures that look like API
10
- // incompatibilities.
6
+ // Keep the modular SDK v2 server on one physical copy: its McpServer class
7
+ // carries private state, so duplicate copies produce nominal TypeScript failures
8
+ // that look like API incompatibilities.
11
9
  const here = createRequire(import.meta.url);
12
10
 
13
11
  const resolveFrom = (specifier: string, target: string): string =>
14
12
  realpathSync(createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(target));
15
13
 
16
- describe('MCP SDK packages are each installed exactly once', () => {
17
- const LEGACY_SERVER = '@modelcontextprotocol/sdk/server/mcp.js';
14
+ describe('the MCP server SDK is installed exactly once', () => {
18
15
  const MODERN_SERVER = '@modelcontextprotocol/server';
19
16
 
20
- it('keeps the retained legacy lane used by agents at the root', () => {
21
- const root = realpathSync(here.resolve(LEGACY_SERVER));
22
- expect(resolveFrom('agents', LEGACY_SERVER)).toBe(root);
23
- });
24
-
25
17
  it('keeps the SDK v2 server shared by this package and mcp-utils', () => {
26
18
  expect(resolveFrom('@chrischall/mcp-utils', MODERN_SERVER)).toBe(
27
19
  realpathSync(here.resolve(MODERN_SERVER)),
@@ -9,9 +9,8 @@ import { diagnose } from '../../src/tools/utils.js';
9
9
  *
10
10
  * Google API error (401 authError): Invalid Credentials
11
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:
12
+ * That is the shape gog prints for a dead Google credential. Losing it is
13
+ * strictly worse than the `row 401` false positive that motivated the change:
15
14
  * a false positive sends someone on a pointless re-auth, but this leaves a REAL
16
15
  * dead credential with no hint at all.
17
16
  */
@@ -171,16 +171,17 @@ describe('gog_auth_add', () => {
171
171
 
172
172
  describe('gog_auth_health', () => {
173
173
  it('names itself as the only live measurement of the Google layer', async () => {
174
- // DEFECT 1's wording half: the hosted connector's "connected" / "refreshed"
175
- // is an OAuth refresh inside OAUTH_KV that contacts neither Fly nor Google.
176
- // Nothing in this repo can change that word, so the tool that DOES measure
177
- // has to say that it is the one that does.
174
+ // A hosted connector's "connected" / "refreshed" is the client's OAuth to
175
+ // the MCP host, which never contacts Google. Nothing in this repo can change
176
+ // that word, so the tool that DOES measure has to say that it is the one
177
+ // that does.
178
178
  const harness = await setupHandlers();
179
179
  const { tools } = await harness.client.listTools();
180
180
  const desc = tools.find((t) => t.name === 'gog_auth_health')!.description!;
181
181
 
182
182
  expect(desc).toMatch(/connected|refreshed/i);
183
183
  expect(desc).toMatch(/only|nothing else/i);
184
+ expect(desc).not.toMatch(/connector key|gog machine/i);
184
185
  await harness.close();
185
186
  });
186
187
 
@@ -468,14 +468,22 @@ describe('gog_drive_read_bytes', () => {
468
468
  expect(res.resource.uri).toBe('gogdrive://f2/file');
469
469
  });
470
470
 
471
- it('surfaces the connector-degradation error via diagnose', async () => {
471
+ it('surfaces a byte-fetch failure via diagnose', async () => {
472
472
  vi.mocked(runner.run)
473
473
  .mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }))
474
474
  .mockResolvedValueOnce('user@x.com'); // diagnose -> auth list
475
- vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('Raw byte retrieval is not available over the hosted connector'));
475
+ vi.mocked(runner.runBinary).mockRejectedValueOnce(new Error('gog exited with code 1'));
476
476
  const harness = await setupHandlers();
477
477
  const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
478
478
  expect(result.isError).toBe(true);
479
- expect(result.content[0].text).toContain('not available over the hosted connector');
479
+ expect(result.content[0].text).toContain('gog exited with code 1');
480
+ });
481
+
482
+ it('no longer claims the hosted transport cannot carry bytes', async () => {
483
+ const harness = await setupHandlers();
484
+ const { tools } = await harness.client.listTools();
485
+ const desc = tools.find((t) => t.name === 'gog_drive_read_bytes')!.description!;
486
+ expect(desc).not.toMatch(/text-only|hosted connector/i);
487
+ await harness.close();
480
488
  });
481
489
  });
@@ -304,8 +304,8 @@ describe('gog_gmail_send', () => {
304
304
 
305
305
  // ==========================================================================
306
306
  // INLINE ATTACHMENT BYTES — for callers with no filesystem in common with gog
307
- // (the hosted connector, any GOG_RUNNER_URL backend). The bytes ride with the
308
- // call and the executor materializes them beside gog.
307
+ // (a hosted deployment such as mcp-host). The bytes ride with the call and
308
+ // the runner materializes them beside gog.
309
309
  // ==========================================================================
310
310
  it('turns attachInline bytes into repeatable --attach file args', async () => {
311
311
  vi.mocked(runner.run).mockResolvedValue('{}');
@@ -1,13 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as runner from '../../src/runner.js';
3
3
  import { runOrDiagnose, pushPaginationFlags, formatAccountList, formatAuthHealth } from '../../src/tools/utils.js';
4
- import { RunnerTransportError } from '../../src/connector-runtime.js';
5
4
 
6
- // PARTIAL mock: only `run` is stubbed. A full automock would also replace
7
- // `RunnerTransportError` and `isRunnerTransportError`, and diagnose()'s whole
8
- // point is that it classifies the REAL class structurally — an automocked
9
- // brand check answers undefined for everything and the tests below would pass
10
- // while proving nothing.
5
+ // PARTIAL mock: only `run` is stubbed; everything else stays real.
11
6
  vi.mock('../../src/runner.js', async (importOriginal) => ({
12
7
  ...(await importOriginal<typeof runner>()),
13
8
  run: vi.fn(),
@@ -605,47 +600,3 @@ describe('formatAuthHealth', () => {
605
600
  expect(formatAuthHealth('{"foo":1}', NOW)).toBe('{"foo":1}');
606
601
  });
607
602
  });
608
-
609
- // The connector's own transport failing is NOT the Google credential failing.
610
- // diagnose() must recognise that structurally, from the error's type, because
611
- // the runner's prose ("unauthorized") is indistinguishable from Google's.
612
- describe('diagnose: runner-authored transport failures', () => {
613
- it('blames the runner key, not the Google account, for a transport-auth failure', async () => {
614
- vi.mocked(runner.run)
615
- .mockRejectedValueOnce(
616
- new RunnerTransportError('gog-runner rejected the bearer token; GOG_RUNNER_KEY vs RUNNER_KEY', 'transport-auth', 401),
617
- )
618
- .mockResolvedValueOnce('user@gmail.com');
619
- const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
620
- const text = result.content[0].text as string;
621
- expect(text).not.toContain('gog_auth_add');
622
- expect(text).not.toMatch(/re-authorize the account/i);
623
- expect(text).toContain('GOG_RUNNER_KEY');
624
- expect(text).toContain('RUNNER_KEY');
625
- });
626
-
627
- it('lets the TYPE beat the prose: runner words that read like Google words get no re-auth hint', async () => {
628
- // The regression this whole change exists to prevent. A runner-authored
629
- // failure whose text happens to contain every Google auth signal must still
630
- // not produce Google auth advice — the request never reached Google.
631
- vi.mocked(runner.run)
632
- .mockRejectedValueOnce(
633
- new RunnerTransportError('unauthorized: 401 invalid_grant, token has been expired or revoked', 'transport-request', 400),
634
- )
635
- .mockResolvedValueOnce('user@gmail.com');
636
- const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
637
- const text = result.content[0].text as string;
638
- expect(text).not.toContain('gog_auth_add');
639
- expect(text).not.toContain('often transient');
640
- });
641
-
642
- it('advises a retry for a retryable transport failure', async () => {
643
- vi.mocked(runner.run)
644
- .mockRejectedValueOnce(new RunnerTransportError('gog-runner is restarting; retry this call.', 'transport-retryable', 503))
645
- .mockResolvedValueOnce('user@gmail.com');
646
- const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
647
- const text = result.content[0].text as string;
648
- expect(text).toContain('often transient');
649
- expect(text).not.toContain('gog_auth_add');
650
- });
651
- });