gogcli-mcp 2.21.1 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +119 -8
- package/dist/lib.js +119 -8
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/auth-log.ts +59 -1
- package/src/connector-auth.ts +265 -8
- package/src/connector-runtime.ts +186 -1
- package/src/google-probe.ts +113 -0
- package/src/timestamps.ts +7 -0
- package/src/tools/auth.ts +8 -2
- package/src/worker.ts +25 -6
- package/tests/auth-log.test.ts +24 -2
- package/tests/connector-auth.test.ts +539 -8
- package/tests/connector-runtime.test.ts +447 -1
- package/tests/google-probe.test.ts +116 -0
- package/tests/timestamps.test.ts +52 -0
- package/tests/tools/auth.test.ts +28 -0
- package/tests/worker.test.ts +33 -8
|
@@ -1,28 +1,559 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
gogAuth,
|
|
6
|
+
CONNECTOR_INSTRUCTIONS,
|
|
7
|
+
GOOGLE_PROBE_TIMEOUT_MS,
|
|
8
|
+
LOGIN_ATTEMPT_TIMEOUT_MS,
|
|
9
|
+
LOGIN_RETRY_DELAY_MS,
|
|
10
|
+
} from '../src/connector-auth.js';
|
|
3
11
|
|
|
4
12
|
afterEach(() => {
|
|
5
13
|
vi.unstubAllGlobals();
|
|
14
|
+
vi.restoreAllMocks();
|
|
6
15
|
});
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
const env = { FLY_ENDPOINT: 'https://runner.example' };
|
|
18
|
+
|
|
19
|
+
/** A `fetch` that answers layer 1 and layer 2 separately. */
|
|
20
|
+
function routedFetch(googleProbe: () => unknown) {
|
|
21
|
+
return vi.fn(async (url: string) => {
|
|
22
|
+
if (url.endsWith('/health')) return { ok: true, status: 200 };
|
|
23
|
+
if (url.endsWith('/health/google')) return googleProbe();
|
|
24
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A probe response object shaped like the runner's JSON answer. */
|
|
29
|
+
const probeBody = (body: unknown, status = 200) => ({
|
|
30
|
+
ok: status >= 200 && status < 300,
|
|
31
|
+
status,
|
|
32
|
+
json: async () => body,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const PREFIX = 'gog-auth ';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Capture the two stderr-safe console methods, and the four Node routes to
|
|
39
|
+
* STDOUT — which is the JSON-RPC channel, so this module must never touch them.
|
|
40
|
+
*/
|
|
41
|
+
function captureLog() {
|
|
42
|
+
const emitted: Array<{ method: 'warn' | 'error'; line: string }> = [];
|
|
43
|
+
const toStdout: string[] = [];
|
|
44
|
+
for (const method of ['warn', 'error'] as const) {
|
|
45
|
+
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
|
46
|
+
emitted.push({ method, line: args.map(String).join(' ') });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const method of ['log', 'info', 'debug', 'trace'] as const) {
|
|
50
|
+
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
|
51
|
+
toStdout.push(args.map(String).join(' '));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
emitted,
|
|
56
|
+
toStdout,
|
|
57
|
+
records(): Record<string, unknown>[] {
|
|
58
|
+
return emitted.map((e) => {
|
|
59
|
+
expect(e.line.startsWith(PREFIX)).toBe(true);
|
|
60
|
+
return JSON.parse(e.line.slice(PREFIX.length)) as Record<string, unknown>;
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
10
65
|
|
|
66
|
+
describe('gogAuth.login', () => {
|
|
11
67
|
it('verifies the key against the backend /health and returns the props', async () => {
|
|
12
|
-
|
|
68
|
+
captureLog();
|
|
69
|
+
const fetchMock = routedFetch(() => probeBody({ ok: true, measured: true, accounts: [] }));
|
|
13
70
|
vi.stubGlobal('fetch', fetchMock);
|
|
14
71
|
|
|
15
72
|
const props = await gogAuth.login({ key: 'my-key' }, env);
|
|
16
73
|
expect(props).toEqual({ key: 'my-key' });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
});
|
|
74
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
75
|
+
expect(url).toBe('https://runner.example/health');
|
|
76
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer my-key' });
|
|
77
|
+
// A key check that can hang forever is a login the user abandons.
|
|
78
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
79
|
+
expect(LOGIN_ATTEMPT_TIMEOUT_MS).toBeLessThanOrEqual(10_000);
|
|
20
80
|
});
|
|
21
81
|
|
|
22
82
|
it('throws when the backend rejects the key', async () => {
|
|
23
|
-
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false })));
|
|
83
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401 })));
|
|
24
84
|
await expect(gogAuth.login({ key: 'bad' }, env)).rejects.toThrow(
|
|
25
85
|
'Invalid connector key',
|
|
26
86
|
);
|
|
27
87
|
});
|
|
28
88
|
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* DEFECT 1: the status check measured nothing it reported.
|
|
92
|
+
*
|
|
93
|
+
* `login()` verified the connector key against `/health`, an endpoint whose own
|
|
94
|
+
* comment says it "does not depend on gog" — so a successful login proved layer
|
|
95
|
+
* 1 (the bearer key reaches the box) and NOTHING about layer 2 (whether Google
|
|
96
|
+
* still accepts the refresh token on the Fly volume). The user was told
|
|
97
|
+
* "connected", twice, and the next Gmail call failed with a Google 401.
|
|
98
|
+
*
|
|
99
|
+
* These tests pin the fix and, more importantly, its shape: the connect path
|
|
100
|
+
* now MEASURES the Google layer and RECORDS what it found — and never, under
|
|
101
|
+
* any outcome, refuses the login on the strength of that measurement.
|
|
102
|
+
*/
|
|
103
|
+
describe('the connect-time Google-layer measurement', () => {
|
|
104
|
+
it('probes layer 2 after the key check, with the same bearer and a bounded timeout', async () => {
|
|
105
|
+
captureLog();
|
|
106
|
+
const fetchMock = routedFetch(() => probeBody({ ok: true, measured: true, accounts: [] }));
|
|
107
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
108
|
+
|
|
109
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
110
|
+
|
|
111
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
112
|
+
const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
113
|
+
expect(url).toBe('https://runner.example/health/google');
|
|
114
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer my-key' });
|
|
115
|
+
// A login that hangs on a status probe is a login the user abandons.
|
|
116
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
117
|
+
expect(GOOGLE_PROBE_TIMEOUT_MS).toBeLessThanOrEqual(5_000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('records a healthy Google layer as its own transition', async () => {
|
|
121
|
+
const log = captureLog();
|
|
122
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({ ok: true, measured: true, accounts: [{ email: 'a@b.c' }] })));
|
|
123
|
+
|
|
124
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
125
|
+
|
|
126
|
+
expect(log.records()).toHaveLength(1);
|
|
127
|
+
const [record] = log.records();
|
|
128
|
+
expect(record.event).toBe('connect.google-ok');
|
|
129
|
+
expect(record.endpoint).toBe('https://runner.example');
|
|
130
|
+
expect(log.emitted[0].method).toBe('warn');
|
|
131
|
+
expect(log.toStdout).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('THE LOCKOUT GUARD: a dead Google credential is recorded, never used to refuse the login', async () => {
|
|
135
|
+
// The re-authorization tools (gog_auth_add_url / gog_auth_add_complete) are
|
|
136
|
+
// MCP tools, reachable only AFTER the connector is connected. Failing login
|
|
137
|
+
// on a dead Google credential would lock the user out of the only path that
|
|
138
|
+
// repairs it. Honesty is achieved by RECORDING, not by refusing.
|
|
139
|
+
const log = captureLog();
|
|
140
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({
|
|
141
|
+
ok: false,
|
|
142
|
+
// The runner states this explicitly: gog reached Google and Google said
|
|
143
|
+
// no. Only that assertion licenses the `-unhealthy` verdict below.
|
|
144
|
+
measured: true,
|
|
145
|
+
accounts: [],
|
|
146
|
+
error: 'invalid_grant: the stored Google refresh token is expired or revoked — re-authorize the account',
|
|
147
|
+
})));
|
|
148
|
+
|
|
149
|
+
const props = await gogAuth.login({ key: 'my-key' }, env);
|
|
150
|
+
|
|
151
|
+
expect(props).toEqual({ key: 'my-key' });
|
|
152
|
+
const [record] = log.records();
|
|
153
|
+
expect(record.event).toBe('connect.google-unhealthy');
|
|
154
|
+
expect(record.reason).toContain('invalid_grant');
|
|
155
|
+
// A dead grant is a failure, so it goes to console.error, not console.warn.
|
|
156
|
+
expect(log.emitted[0].method).toBe('error');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('a runner that claims ok WITHOUT claiming it measured is recorded as unmeasured', async () => {
|
|
160
|
+
// `connect.google-ok` asserts the credential was measured live and passed,
|
|
161
|
+
// and its counterpart `refusal.google-ok` is the record documented as "the
|
|
162
|
+
// one record that means we cannot explain this" — logged at error level and
|
|
163
|
+
// held up as the only evidence that could justify automatic recovery on the
|
|
164
|
+
// hosted path. Neither may be built on a bare `ok:true`: an affirmative
|
|
165
|
+
// field is still only a claim, and a health claim with no measurement
|
|
166
|
+
// behind it is exactly the defect this branch exists to delete. No current
|
|
167
|
+
// runner emits this shape (the endpoint always sends both fields), which is
|
|
168
|
+
// precisely why it needs a test rather than a reviewer.
|
|
169
|
+
const log = captureLog();
|
|
170
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({ ok: true, accounts: [{ email: 'a@b.c' }] })));
|
|
171
|
+
|
|
172
|
+
// Still connects — recording is never refusing. See THE LOCKOUT GUARD.
|
|
173
|
+
await expect(gogAuth.login({ key: 'my-key' }, env)).resolves.toEqual({ key: 'my-key' });
|
|
174
|
+
|
|
175
|
+
const [record] = log.records();
|
|
176
|
+
expect(record.event).toBe('connect.google-unmeasured');
|
|
177
|
+
expect(record.reason).toMatch(/did not report whether/i);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('says so plainly when the runner reports unhealthy without a cause', async () => {
|
|
181
|
+
const log = captureLog();
|
|
182
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({ ok: false, measured: true })));
|
|
183
|
+
|
|
184
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
185
|
+
|
|
186
|
+
const [record] = log.records();
|
|
187
|
+
expect(record.event).toBe('connect.google-unhealthy');
|
|
188
|
+
expect(record.reason).toMatch(/no cause/i);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('REVIEW DEFECT: a probe that could not RUN is never recorded as "Google refused"', async () => {
|
|
192
|
+
// The mirror image of the defect this branch fixes. `server.mjs` answers
|
|
193
|
+
// `ok:false` for causes that describe the PROBE — it timed out, it could not
|
|
194
|
+
// be run at all (no `gog` on PATH, no `credentials.json` on the volume), its
|
|
195
|
+
// output could not be parsed — and reading `ok !== true` as a refusal filed
|
|
196
|
+
// every one of them at error level under an event that means "Google was
|
|
197
|
+
// asked and refused". An operator grepping event names would close the
|
|
198
|
+
// incident on evidence nobody gathered.
|
|
199
|
+
const log = captureLog();
|
|
200
|
+
for (const error of [
|
|
201
|
+
'the Google probe timed out before gog answered',
|
|
202
|
+
'the Google probe could not be run',
|
|
203
|
+
'gog auth list --check returned unrecognized output',
|
|
204
|
+
'gog did not report token validity',
|
|
205
|
+
'gog reported an account it explicitly did not check',
|
|
206
|
+
]) {
|
|
207
|
+
log.emitted.length = 0;
|
|
208
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({ ok: false, measured: false, error })));
|
|
209
|
+
|
|
210
|
+
const props = await gogAuth.login({ key: 'my-key' }, env);
|
|
211
|
+
|
|
212
|
+
expect(props).toEqual({ key: 'my-key' });
|
|
213
|
+
const [record] = log.records();
|
|
214
|
+
expect(record.event).toBe('connect.google-unmeasured');
|
|
215
|
+
// The runner's own words still ride along — under-claiming the verdict
|
|
216
|
+
// costs the operator nothing, because the cause is on the same line.
|
|
217
|
+
expect(record.reason).toBe(error);
|
|
218
|
+
// warn, not error: the absence of a measurement is not evidence.
|
|
219
|
+
expect(log.emitted[0].method).toBe('warn');
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('will not claim ill health from a runner that never said it measured', async () => {
|
|
224
|
+
// Silence is not a measurement, so `ok:false` alone buys no verdict.
|
|
225
|
+
const log = captureLog();
|
|
226
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({ ok: false, error: 'something went wrong' })));
|
|
227
|
+
|
|
228
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
229
|
+
|
|
230
|
+
const [record] = log.records();
|
|
231
|
+
expect(record.event).toBe('connect.google-unmeasured');
|
|
232
|
+
expect(record.reason).toContain('something went wrong');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('calls a runner that cannot answer the probe UNMEASURED, never unhealthy', async () => {
|
|
236
|
+
// A runner older than the probe endpoint answers 404. Reporting that as
|
|
237
|
+
// "your Google credential is dead" would rebuild the very defect this
|
|
238
|
+
// measurement exists to remove: a claim about health nobody measured.
|
|
239
|
+
const log = captureLog();
|
|
240
|
+
vi.stubGlobal('fetch', routedFetch(() => probeBody({}, 404)));
|
|
241
|
+
|
|
242
|
+
const props = await gogAuth.login({ key: 'my-key' }, env);
|
|
243
|
+
|
|
244
|
+
expect(props).toEqual({ key: 'my-key' });
|
|
245
|
+
const [record] = log.records();
|
|
246
|
+
expect(record.event).toBe('connect.google-unmeasured');
|
|
247
|
+
expect(record.reason).toContain('404');
|
|
248
|
+
expect(log.emitted[0].method).toBe('warn');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('swallows a probe whose fetch rejects, and still logs in', async () => {
|
|
252
|
+
const log = captureLog();
|
|
253
|
+
vi.stubGlobal('fetch', routedFetch(() => {
|
|
254
|
+
throw Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' });
|
|
255
|
+
}));
|
|
256
|
+
|
|
257
|
+
const props = await gogAuth.login({ key: 'my-key' }, env);
|
|
258
|
+
|
|
259
|
+
expect(props).toEqual({ key: 'my-key' });
|
|
260
|
+
const [record] = log.records();
|
|
261
|
+
expect(record.event).toBe('connect.google-unmeasured');
|
|
262
|
+
expect(record.reason).toContain('aborted');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('swallows a non-Error rejection too', async () => {
|
|
266
|
+
const log = captureLog();
|
|
267
|
+
vi.stubGlobal('fetch', routedFetch(() => {
|
|
268
|
+
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
269
|
+
throw 'socket hang up';
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
await expect(gogAuth.login({ key: 'my-key' }, env)).resolves.toEqual({ key: 'my-key' });
|
|
273
|
+
expect(log.records()[0].reason).toContain('socket hang up');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('treats a body it cannot parse as unmeasured', async () => {
|
|
277
|
+
// A proxy or an error page in front of the runner answers 200 with HTML.
|
|
278
|
+
const log = captureLog();
|
|
279
|
+
vi.stubGlobal('fetch', routedFetch(() => ({
|
|
280
|
+
ok: true,
|
|
281
|
+
status: 200,
|
|
282
|
+
json: async () => { throw new SyntaxError('Unexpected token < in JSON'); },
|
|
283
|
+
})));
|
|
284
|
+
|
|
285
|
+
await expect(gogAuth.login({ key: 'my-key' }, env)).resolves.toEqual({ key: 'my-key' });
|
|
286
|
+
expect(log.records()[0].event).toBe('connect.google-unmeasured');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('never lets the probe leak the connector key into a log line', async () => {
|
|
290
|
+
const log = captureLog();
|
|
291
|
+
vi.stubGlobal('fetch', routedFetch(() => {
|
|
292
|
+
throw new Error('connect ECONNREFUSED using Bearer sk-connector-key-secret');
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
await gogAuth.login({ key: 'sk-connector-key-secret' }, env);
|
|
296
|
+
|
|
297
|
+
const line = log.emitted.map((e) => e.line).join('\n');
|
|
298
|
+
expect(line).not.toContain('sk-connector-key-secret');
|
|
299
|
+
expect(line).toContain('[REDACTED]');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The wording half of the same defect. claude.ai's "connected" / "refreshed" is
|
|
305
|
+
* out of our control — `ConnectorAuth` exposes only a `login` hook, so
|
|
306
|
+
* "refreshed" is an OAuth refresh inside OAUTH_KV that contacts neither Fly nor
|
|
307
|
+
* Google. What IS in our control is every sentence the connector itself writes.
|
|
308
|
+
*/
|
|
309
|
+
describe('CONNECTOR_INSTRUCTIONS', () => {
|
|
310
|
+
it('tells the model what a connected connector does and does not prove', () => {
|
|
311
|
+
expect(CONNECTOR_INSTRUCTIONS).toMatch(/connected|refreshed/i);
|
|
312
|
+
expect(CONNECTOR_INSTRUCTIONS).toMatch(/does not|never/i);
|
|
313
|
+
// The one tool that performs a live refresh against Google.
|
|
314
|
+
expect(CONNECTOR_INSTRUCTIONS).toContain('gog_auth_health');
|
|
315
|
+
// And the repair path, so a reader who finds a dead grant is not stranded.
|
|
316
|
+
expect(CONNECTOR_INSTRUCTIONS).toContain('gog_auth_add_url');
|
|
317
|
+
expect(CONNECTOR_INSTRUCTIONS).toContain('gog_auth_add_complete');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('is wired into every hosted agent, not merely exported', () => {
|
|
321
|
+
// worker.ts cannot load under the node pool (it imports the Worker-only
|
|
322
|
+
// `agents` runtime), so its wiring is asserted as source text — the same
|
|
323
|
+
// technique the runner suite uses to pin fly.toml's min_machines_running.
|
|
324
|
+
const worker = readFileSync(
|
|
325
|
+
fileURLToPath(new URL('../src/worker.ts', import.meta.url)),
|
|
326
|
+
'utf8',
|
|
327
|
+
);
|
|
328
|
+
expect(worker).toContain('CONNECTOR_INSTRUCTIONS');
|
|
329
|
+
expect(worker).toMatch(/instructions:\s*CONNECTOR_INSTRUCTIONS/);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* DEFECT 4: a connector that never completed enrolment.
|
|
335
|
+
*
|
|
336
|
+
* `gog_docs` (and, in the same inventory, `gog_sheets` and `gog_drive`) exposed
|
|
337
|
+
* only `authenticate` / `complete_authentication` — the signature of a connector
|
|
338
|
+
* whose LAYER 1 enrolment never finished. Nothing about that is docs-specific,
|
|
339
|
+
* which points away from the docs tools and at the one place enrolment can fail:
|
|
340
|
+
* `login()`.
|
|
341
|
+
*
|
|
342
|
+
* And `login()` had exactly one failure mode. Every non-2xx — and only a non-2xx,
|
|
343
|
+
* because a rejected `fetch` was not caught at all — produced "Invalid connector
|
|
344
|
+
* key (backend rejected it)". But the runner answers 503 `{retryable:true}` for
|
|
345
|
+
* the whole of its drain window, i.e. during EVERY deploy (`server.mjs`, the
|
|
346
|
+
* `server.shuttingDown` guard), and Fly's proxy answers 502 while a stopped
|
|
347
|
+
* Machine boots. A user who enrolled during either was told their key was wrong.
|
|
348
|
+
* The rational response to "your key is wrong" is to stop, which leaves precisely
|
|
349
|
+
* the half-enrolled connector observed.
|
|
350
|
+
*
|
|
351
|
+
* So the fix is to tell the truth about WHICH layer refused, and — because the
|
|
352
|
+
* transient case is a normal consequence of deploying — to absorb it by retrying
|
|
353
|
+
* rather than reporting it at all.
|
|
354
|
+
*
|
|
355
|
+
* Note the direction of travel: this makes login STRICTLY MORE permissive. It can
|
|
356
|
+
* only turn a refusal into a success, never the reverse, so it cannot strand a
|
|
357
|
+
* user outside the connector the way a Google-layer gate would.
|
|
358
|
+
*/
|
|
359
|
+
describe('login() tells a rejected key apart from an unreachable backend', () => {
|
|
360
|
+
/** A `/health` that answers `results` in order, then repeats the last one. */
|
|
361
|
+
function healthSequence(...results: Array<() => unknown>) {
|
|
362
|
+
let i = 0;
|
|
363
|
+
return vi.fn(async (url: string) => {
|
|
364
|
+
if (url.endsWith('/health/google')) return probeBody({ ok: true, measured: true, accounts: [] });
|
|
365
|
+
const step = results[Math.min(i, results.length - 1)];
|
|
366
|
+
i += 1;
|
|
367
|
+
return step();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const reject = (err: unknown) => () => {
|
|
372
|
+
throw err;
|
|
373
|
+
};
|
|
374
|
+
const status = (code: number) => () => ({ ok: false, status: code });
|
|
375
|
+
|
|
376
|
+
it('the retry is short enough that the user never sees it as a hang', () => {
|
|
377
|
+
// login() runs inside the /authorize POST the human is watching and
|
|
378
|
+
// claude.ai is timing. Two attempts plus one delay must stay well inside
|
|
379
|
+
// any sane authorize budget.
|
|
380
|
+
expect(LOGIN_RETRY_DELAY_MS).toBeLessThan(1_000);
|
|
381
|
+
expect(2 * LOGIN_ATTEMPT_TIMEOUT_MS + LOGIN_RETRY_DELAY_MS).toBeLessThanOrEqual(20_000);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('the WHOLE of login() stays inside the authorize budget, probe included', () => {
|
|
385
|
+
// The bound above covered only the key check, but login() also runs the
|
|
386
|
+
// Google probe — and the two worst cases compose: an unreachable Machine
|
|
387
|
+
// burns both attempts and the delay, and THEN the probe hangs to its own
|
|
388
|
+
// timeout. That total is the number the human actually waits, so it is the
|
|
389
|
+
// number that gets asserted. Chosen, not inherited: 5s + 0.25s + 5s + 4s.
|
|
390
|
+
const worstCaseMs =
|
|
391
|
+
2 * LOGIN_ATTEMPT_TIMEOUT_MS + LOGIN_RETRY_DELAY_MS + GOOGLE_PROBE_TIMEOUT_MS;
|
|
392
|
+
expect(worstCaseMs).toBeLessThanOrEqual(20_000);
|
|
393
|
+
// And the probe is never the dominant term: a diagnostic that outweighs the
|
|
394
|
+
// check it follows has stopped being a diagnostic.
|
|
395
|
+
expect(GOOGLE_PROBE_TIMEOUT_MS).toBeLessThan(LOGIN_ATTEMPT_TIMEOUT_MS);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('probes once per enrolment — deliberately unthrottled, unlike the refusal probe', async () => {
|
|
399
|
+
// connector-runtime.ts throttles ITS probe to one a minute because a model
|
|
400
|
+
// retrying a refused call can fire it in a loop. This one cannot loop: it is
|
|
401
|
+
// reached only by a human completing an enrolment, at most once per
|
|
402
|
+
// connector. Five connectors set up back to back means five `gog auth list
|
|
403
|
+
// --check` spawns spread across five human interactions, which the Fly box
|
|
404
|
+
// handles comfortably. So the throttle is omitted on purpose, not by
|
|
405
|
+
// oversight — and this test fails if login() ever grows a second probe.
|
|
406
|
+
captureLog();
|
|
407
|
+
const fetchMock = routedFetch(() => probeBody({ ok: true, measured: true, accounts: [] }));
|
|
408
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
409
|
+
|
|
410
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
411
|
+
await gogAuth.login({ key: 'my-key' }, env);
|
|
412
|
+
|
|
413
|
+
const probes = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/health/google'));
|
|
414
|
+
expect(probes).toHaveLength(2);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('blames the key ONLY on 401, and does not retry a settled answer', async () => {
|
|
418
|
+
const log = captureLog();
|
|
419
|
+
const fetchMock = healthSequence(status(401));
|
|
420
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
421
|
+
|
|
422
|
+
await expect(gogAuth.login({ key: 'bad' }, env)).rejects.toThrow(
|
|
423
|
+
'Invalid connector key (backend rejected it)',
|
|
424
|
+
);
|
|
425
|
+
// A rejection is an answer. Asking again would only be slower.
|
|
426
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
427
|
+
expect(log.records()[0].event).toBe('connect.key-rejected');
|
|
428
|
+
expect(log.emitted[0].method).toBe('error');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('blames the key on 403 too', async () => {
|
|
432
|
+
captureLog();
|
|
433
|
+
vi.stubGlobal('fetch', healthSequence(status(403)));
|
|
434
|
+
await expect(gogAuth.login({ key: 'bad' }, env)).rejects.toThrow(
|
|
435
|
+
'Invalid connector key',
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('a 503 from the drain window is retried, and a redeploy stops costing an enrolment', async () => {
|
|
440
|
+
// This is the whole defect in one test: the runner returns exactly this for
|
|
441
|
+
// the length of every deploy, and it used to end the user's enrolment.
|
|
442
|
+
const log = captureLog();
|
|
443
|
+
const fetchMock = healthSequence(status(503), () => ({ ok: true, status: 200 }));
|
|
444
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
445
|
+
|
|
446
|
+
await expect(gogAuth.login({ key: 'good' }, env)).resolves.toEqual({ key: 'good' });
|
|
447
|
+
// /health twice, then the Google probe.
|
|
448
|
+
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
|
|
449
|
+
'https://runner.example/health',
|
|
450
|
+
'https://runner.example/health',
|
|
451
|
+
'https://runner.example/health/google',
|
|
452
|
+
]);
|
|
453
|
+
// A transient blip that the retry absorbed is not an auth failure.
|
|
454
|
+
expect(log.records().map((r) => r.event)).toEqual(['connect.google-ok']);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('says the backend is unreachable — never that the key is wrong — when it stays down', async () => {
|
|
458
|
+
const log = captureLog();
|
|
459
|
+
const fetchMock = healthSequence(status(503));
|
|
460
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
461
|
+
|
|
462
|
+
const err = await gogAuth.login({ key: 'good' }, env).catch((e: Error) => e);
|
|
463
|
+
|
|
464
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
465
|
+
expect(err).toBeInstanceOf(Error);
|
|
466
|
+
const message = (err as Error).message;
|
|
467
|
+
expect(message).toContain('503');
|
|
468
|
+
expect(message).toMatch(/NOT rejected/);
|
|
469
|
+
expect(message).toMatch(/try again/i);
|
|
470
|
+
expect(message).not.toMatch(/invalid connector key/i);
|
|
471
|
+
expect(log.records()[0].event).toBe('connect.runner-unreachable');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('MUST NOT REGRESS: an unreachable backend never produces Google re-auth advice', async () => {
|
|
475
|
+
// The layer the user cannot see is Google; the layer that just failed is
|
|
476
|
+
// transport. Conflating them is the exact defect 2.21.1 fixed on the /run
|
|
477
|
+
// path, and it must not reappear on the enrolment path.
|
|
478
|
+
captureLog();
|
|
479
|
+
vi.stubGlobal('fetch', healthSequence(status(502)));
|
|
480
|
+
|
|
481
|
+
const err = await gogAuth.login({ key: 'good' }, env).catch((e: Error) => e);
|
|
482
|
+
|
|
483
|
+
expect((err as Error).message).not.toMatch(/google|re-?authoriz|refresh token|invalid_grant/i);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('treats a rejected fetch as unreachable, not as a bad key', async () => {
|
|
487
|
+
// This case did not merely report the wrong cause — it was never caught at
|
|
488
|
+
// all, so it surfaced as an unhandled failure inside /authorize.
|
|
489
|
+
const log = captureLog();
|
|
490
|
+
const fetchMock = healthSequence(reject(new Error('connect ECONNREFUSED 10.0.0.1:8080')));
|
|
491
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
492
|
+
|
|
493
|
+
const err = await gogAuth.login({ key: 'good' }, env).catch((e: Error) => e);
|
|
494
|
+
|
|
495
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
496
|
+
expect((err as Error).message).toContain('ECONNREFUSED');
|
|
497
|
+
expect((err as Error).message).not.toMatch(/invalid connector key/i);
|
|
498
|
+
expect(log.records()[0].event).toBe('connect.runner-unreachable');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('survives a non-Error rejection', async () => {
|
|
502
|
+
captureLog();
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
504
|
+
vi.stubGlobal('fetch', healthSequence(reject('socket hang up')));
|
|
505
|
+
|
|
506
|
+
const err = await gogAuth.login({ key: 'good' }, env).catch((e: Error) => e);
|
|
507
|
+
|
|
508
|
+
expect((err as Error).message).toContain('socket hang up');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('recovers when the first attempt cannot connect and the second can', async () => {
|
|
512
|
+
captureLog();
|
|
513
|
+
const fetchMock = healthSequence(reject(new Error('network error')), () => ({ ok: true, status: 200 }));
|
|
514
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
515
|
+
|
|
516
|
+
await expect(gogAuth.login({ key: 'good' }, env)).resolves.toEqual({ key: 'good' });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('FAILS SAFE: a response carrying no status is transient, never a bad key', async () => {
|
|
520
|
+
// Anything that reaches here without a status is something we do not
|
|
521
|
+
// recognise. The costly mistake is telling a user with a perfectly good key
|
|
522
|
+
// that it is wrong, so an unknown answer resolves toward "try again".
|
|
523
|
+
const log = captureLog();
|
|
524
|
+
const fetchMock = healthSequence(() => ({ ok: false }));
|
|
525
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
526
|
+
|
|
527
|
+
const err = await gogAuth.login({ key: 'good' }, env).catch((e: Error) => e);
|
|
528
|
+
|
|
529
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
530
|
+
expect((err as Error).message).not.toMatch(/invalid connector key/i);
|
|
531
|
+
expect((err as Error).message).toMatch(/no HTTP status/i);
|
|
532
|
+
expect(log.records()[0].event).toBe('connect.runner-unreachable');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('never lets the connector key reach the error the login page shows', async () => {
|
|
536
|
+
const log = captureLog();
|
|
537
|
+
vi.stubGlobal('fetch', healthSequence(
|
|
538
|
+
reject(new Error('proxy error while sending Bearer sk-connector-key-secret')),
|
|
539
|
+
));
|
|
540
|
+
|
|
541
|
+
const err = await gogAuth.login({ key: 'sk-connector-key-secret' }, env).catch((e: Error) => e);
|
|
542
|
+
|
|
543
|
+
expect((err as Error).message).not.toContain('sk-connector-key-secret');
|
|
544
|
+
expect((err as Error).message).toContain('[REDACTED]');
|
|
545
|
+
expect(log.emitted.map((e) => e.line).join('\n')).not.toContain('sk-connector-key-secret');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('does not probe the Google layer when enrolment itself failed', async () => {
|
|
549
|
+
// Nothing may be recorded about Google on a login that never happened.
|
|
550
|
+
const log = captureLog();
|
|
551
|
+
const fetchMock = healthSequence(status(401));
|
|
552
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
553
|
+
|
|
554
|
+
await expect(gogAuth.login({ key: 'bad' }, env)).rejects.toThrow();
|
|
555
|
+
|
|
556
|
+
expect(fetchMock.mock.calls.some((c) => String(c[0]).endsWith('/health/google'))).toBe(false);
|
|
557
|
+
expect(log.records().every((r) => !String(r.event).startsWith('connect.google'))).toBe(true);
|
|
558
|
+
});
|
|
559
|
+
});
|