gogcli-mcp 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +273 -748
- package/dist/lib.js +375 -785
- package/manifest.json +2 -2
- package/mint.yaml +37 -33
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/attachments.ts +28 -34
- package/src/blob-upload.ts +165 -134
- package/src/blob-urls.ts +3 -5
- package/src/bootstrap-auth.ts +97 -0
- package/src/index.ts +3 -4
- package/src/lib.ts +14 -9
- package/src/runner.ts +23 -175
- package/src/tools/appscript.ts +1 -1
- package/src/tools/auth.ts +5 -5
- package/src/tools/drive.ts +1 -3
- package/src/tools/gmail.ts +9 -9
- package/src/tools/utils.ts +12 -58
- package/tests/attachments.test.ts +11 -14
- package/tests/blob-upload.test.ts +235 -160
- package/tests/bootstrap-auth.test.ts +245 -0
- package/tests/runner-file-args.test.ts +1 -13
- package/tests/runner.test.ts +8 -95
- package/tests/sdk-single-copy.test.ts +4 -12
- package/tests/tools/auth-401-shapes.test.ts +2 -3
- package/tests/tools/auth.test.ts +5 -4
- package/tests/tools/drive.test.ts +11 -3
- package/tests/tools/gmail.test.ts +2 -2
- package/tests/tools/utils.test.ts +1 -50
- package/tests/zod-single-copy.test.ts +6 -14
- package/tsconfig.json +1 -2
- package/vitest.config.ts +2 -12
- package/src/auth-log.ts +0 -205
- package/src/connector-auth.ts +0 -319
- package/src/connector-login.ts +0 -87
- package/src/connector-runtime.ts +0 -910
- package/src/google-probe.ts +0 -113
- package/src/google-token.ts +0 -391
- package/src/remote-runner.ts +0 -77
- package/src/worker.ts +0 -117
- package/tests/auth-log.test.ts +0 -530
- package/tests/connector-auth.test.ts +0 -559
- package/tests/connector-login.test.ts +0 -151
- package/tests/connector-runtime.test.ts +0 -1664
- package/tests/google-probe.test.ts +0 -116
- package/tests/google-token.test.ts +0 -425
- package/tests/remote-runner.test.ts +0 -202
- 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
|
|
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', () => {
|
package/tests/runner.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { spawn as mockedSpawn } from 'node:child_process';
|
|
4
|
-
import { run, runBinary
|
|
5
|
-
import type { Spawner
|
|
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`.
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
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();
|
|
@@ -803,14 +798,6 @@ describe('run', () => {
|
|
|
803
798
|
expect(callArgs).toContain('files.get');
|
|
804
799
|
});
|
|
805
800
|
|
|
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
801
|
it('ignores timeout if close event already settled the promise', async () => {
|
|
815
802
|
vi.useFakeTimers();
|
|
816
803
|
const spawner = vi.fn(() => {
|
|
@@ -906,82 +893,8 @@ describe('run --readonly (gog 0.31)', () => {
|
|
|
906
893
|
});
|
|
907
894
|
});
|
|
908
895
|
|
|
909
|
-
describe('run executor
|
|
910
|
-
it('
|
|
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 () => {
|
|
896
|
+
describe('run executor', () => {
|
|
897
|
+
it('uses the lazily-imported real spawn when no spawner is injected', async () => {
|
|
985
898
|
vi.mocked(mockedSpawn).mockImplementation((() => makeProc(0, '{"real":true}')) as never);
|
|
986
899
|
const result = await run(['sheets', 'get', 'id1', 'A1']);
|
|
987
900
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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
|
|
13
|
-
*
|
|
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
|
*/
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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
|
|
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('
|
|
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('
|
|
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
|
-
// (
|
|
308
|
-
//
|
|
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
|
|
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
|
-
});
|
|
@@ -6,12 +6,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
// The zod counterpart of sdk-single-copy.test.ts, guarding the invariant that
|
|
7
7
|
// broke dependabot #333: the whole monorepo must resolve ONE copy of zod.
|
|
8
8
|
//
|
|
9
|
-
// Same shape of failure, a different package.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// Same shape of failure, a different package. Any dependency that pins zod
|
|
10
|
+
// can take the hoisted root slot that `@chrischall/mcp-utils` and the MCP SDK
|
|
11
|
+
// resolve their zod peer from (#333: a since-removed devDependency's exact pin).
|
|
12
|
+
// The moment our workspaces ask for a newer zod than that pin, each nests its
|
|
13
|
+
// own copy — and because a `ZodType` carries brand-bearing internals,
|
|
14
|
+
// TypeScript compares the two NOMINALLY: every schema our tools hand
|
|
15
15
|
// `registerTool` fails with `TS2322: Type 'ZodString' is not assignable to type
|
|
16
16
|
// 'AnySchema'`, with no API change and nothing to fix in the source. #333 split
|
|
17
17
|
// the tree exactly that way and produced 11,024 type errors from a bump of one
|
|
@@ -45,12 +45,4 @@ describe('zod is installed exactly once', () => {
|
|
|
45
45
|
realpathSync(here.resolve('zod')),
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
|
-
|
|
49
|
-
it('resolves to the same file for @cloudflare/vitest-pool-workers, which exact-pins zod', () => {
|
|
50
|
-
// The exact pin here is what captured the root hoist slot in #333, and it
|
|
51
|
-
// is why the root `overrides` block carries a zod entry.
|
|
52
|
-
expect(resolveFrom('@cloudflare/vitest-pool-workers')).toBe(
|
|
53
|
-
realpathSync(here.resolve('zod')),
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
48
|
});
|