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
|
@@ -790,7 +790,12 @@ describe('makeFlyExecutor re-mints a rejected access token and replays once', ()
|
|
|
790
790
|
vi.stubGlobal('fetch', fetchMock);
|
|
791
791
|
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
792
792
|
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
793
|
-
|
|
793
|
+
// Counted by ENDPOINT, not in total: this same refusal now also takes a
|
|
794
|
+
// read of the Google layer (`/health/google`, see the refusal-probe block
|
|
795
|
+
// below), and that reading is a diagnostic, not a second attempt. What
|
|
796
|
+
// "does not replay" means is that gog ran exactly once.
|
|
797
|
+
const runs = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/run'));
|
|
798
|
+
expect(runs).toHaveLength(1);
|
|
794
799
|
});
|
|
795
800
|
|
|
796
801
|
it('does not replay when the token source cannot invalidate', async () => {
|
|
@@ -1196,3 +1201,444 @@ describe('makeFlyExecutor re-mints a rejected access token and replays once', ()
|
|
|
1196
1201
|
expect(result.content[0].text).toBe('{"threads":[]}');
|
|
1197
1202
|
});
|
|
1198
1203
|
});
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* DEFECT 3, the half of it that survived grounding.
|
|
1207
|
+
*
|
|
1208
|
+
* `worker.ts` builds `makeFlyExecutor(FLY_ENDPOINT, key)` with NO token source,
|
|
1209
|
+
* so the eviction + replay machinery is inert on the hosted path: `gog` runs as
|
|
1210
|
+
* the Fly volume's own identity, and a Google 401 stops at the "no access token
|
|
1211
|
+
* was supplied" guard. That much is by design and stays.
|
|
1212
|
+
*
|
|
1213
|
+
* What did NOT survive is the idea of building a replay for it. `gog` is spawned
|
|
1214
|
+
* fresh per `/run` and re-reads the keyring every time, so there is no
|
|
1215
|
+
* cross-spawn in-memory token that could go stale — a Google 401 here means the
|
|
1216
|
+
* stored credential itself was refused, and no retry can fix that. Building a
|
|
1217
|
+
* retry would have been the fifth plausible theory in a row.
|
|
1218
|
+
*
|
|
1219
|
+
* So this path gets INSTRUMENTATION instead. The one thing nobody could answer
|
|
1220
|
+
* after the incident was: at the moment Google refused that call, was the
|
|
1221
|
+
* refresh token on the volume alive or dead? `replay.declined` records only that
|
|
1222
|
+
* WE did nothing. These tests pin a record of what GOOGLE said, measured at the
|
|
1223
|
+
* moment of the refusal with the probe `/health/google` — and pin that the
|
|
1224
|
+
* measurement changes nothing the caller sees.
|
|
1225
|
+
*/
|
|
1226
|
+
describe('the Google-layer measurement taken when a hosted call is refused', () => {
|
|
1227
|
+
const ENDPOINT = 'https://gogcli-gog-runner.fly.dev';
|
|
1228
|
+
const KEY = 'k';
|
|
1229
|
+
const PREFIX = 'gog-auth ';
|
|
1230
|
+
|
|
1231
|
+
const GOOGLE_401_STDERR =
|
|
1232
|
+
'Google API error (401 authError): Request had invalid authentication credentials.';
|
|
1233
|
+
const READ = ['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'];
|
|
1234
|
+
|
|
1235
|
+
function gogFailed(stderr: string) {
|
|
1236
|
+
return {
|
|
1237
|
+
ok: false,
|
|
1238
|
+
status: 422,
|
|
1239
|
+
json: async () => ({ error: `Command failed: gog gmail search q\n${stderr}`, stderr }),
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
const probeBody = (body: unknown, status = 200) => ({
|
|
1243
|
+
ok: status >= 200 && status < 300,
|
|
1244
|
+
status,
|
|
1245
|
+
json: async () => body,
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
/** A `fetch` that answers `/run` and `/health/google` separately. */
|
|
1249
|
+
function routedFetch(run: () => unknown, probe: () => unknown) {
|
|
1250
|
+
return vi.fn(async (url: string) => {
|
|
1251
|
+
if (url.endsWith('/run')) return run();
|
|
1252
|
+
if (url.endsWith('/health/google')) return probe();
|
|
1253
|
+
throw new Error(`unexpected fetch: ${url}`);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
const urls = (f: { mock: { calls: unknown[][] } }) => f.mock.calls.map((c) => c[0] as string);
|
|
1257
|
+
const runCalls = (f: { mock: { calls: unknown[][] } }) =>
|
|
1258
|
+
urls(f).filter((u) => u.endsWith('/run')).length;
|
|
1259
|
+
const probeCalls = (f: { mock: { calls: unknown[][] } }) =>
|
|
1260
|
+
urls(f).filter((u) => u.endsWith('/health/google')).length;
|
|
1261
|
+
|
|
1262
|
+
function captureLog() {
|
|
1263
|
+
const emitted: Array<{ method: 'warn' | 'error'; line: string }> = [];
|
|
1264
|
+
const toStdout: string[] = [];
|
|
1265
|
+
for (const method of ['warn', 'error'] as const) {
|
|
1266
|
+
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
|
1267
|
+
emitted.push({ method, line: args.map(String).join(' ') });
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
for (const method of ['log', 'info', 'debug', 'trace'] as const) {
|
|
1271
|
+
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
|
1272
|
+
toStdout.push(args.map(String).join(' '));
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
emitted,
|
|
1277
|
+
toStdout,
|
|
1278
|
+
records(): Record<string, unknown>[] {
|
|
1279
|
+
return emitted.map((e) => {
|
|
1280
|
+
expect(e.line.startsWith(PREFIX)).toBe(true);
|
|
1281
|
+
return JSON.parse(e.line.slice(PREFIX.length)) as Record<string, unknown>;
|
|
1282
|
+
});
|
|
1283
|
+
},
|
|
1284
|
+
byEvent(event: string): Record<string, unknown> | undefined {
|
|
1285
|
+
return this.records().find((r) => r.event === event);
|
|
1286
|
+
},
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
it('asks the runner whether Google still accepts the credential, with the same bearer', async () => {
|
|
1291
|
+
const log = captureLog();
|
|
1292
|
+
const fetchMock = routedFetch(
|
|
1293
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1294
|
+
() => probeBody({ ok: true, measured: true, accounts: [{ email: 'a@b.c', valid: true }] }),
|
|
1295
|
+
);
|
|
1296
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1297
|
+
|
|
1298
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
1299
|
+
// The caller's error is untouched — this is instrumentation, not recovery.
|
|
1300
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error \(401/);
|
|
1301
|
+
|
|
1302
|
+
expect(runCalls(fetchMock)).toBe(1);
|
|
1303
|
+
expect(probeCalls(fetchMock)).toBe(1);
|
|
1304
|
+
const [, init] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
1305
|
+
expect(init.headers).toEqual({ Authorization: `Bearer ${KEY}` });
|
|
1306
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it('records an UNEXPLAINED refusal when Google says the credential is fine', async () => {
|
|
1310
|
+
// The narrow theory the plan refused to build a fix for: a stored token
|
|
1311
|
+
// refused by Google while the grant behind it is alive. This record is the
|
|
1312
|
+
// only thing that can ever prove or kill it, so it is emitted at error
|
|
1313
|
+
// level — "we cannot explain this" is the loudest thing a log can say.
|
|
1314
|
+
const log = captureLog();
|
|
1315
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1316
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1317
|
+
() => probeBody({ ok: true, measured: true, accounts: [{ email: 'a@b.c', valid: true }] }),
|
|
1318
|
+
));
|
|
1319
|
+
|
|
1320
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1321
|
+
|
|
1322
|
+
const record = log.byEvent('refusal.google-ok');
|
|
1323
|
+
expect(record).toBeDefined();
|
|
1324
|
+
expect(record!.service).toBe('gmail');
|
|
1325
|
+
expect(record!.endpoint).toBe(ENDPOINT);
|
|
1326
|
+
expect(log.emitted.find((e) => e.line.includes('refusal.google-ok'))!.method).toBe('error');
|
|
1327
|
+
// Still followed by the decision record, so the pair reads: what Google
|
|
1328
|
+
// said, then what we did about it.
|
|
1329
|
+
expect(log.byEvent('replay.declined')).toBeDefined();
|
|
1330
|
+
expect(log.toStdout).toEqual([]);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it('records a dead credential, carrying the runner’s classification and not gog’s words', async () => {
|
|
1334
|
+
const log = captureLog();
|
|
1335
|
+
const cause =
|
|
1336
|
+
'invalid_grant: the stored Google refresh token is expired or revoked — re-authorize the account';
|
|
1337
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1338
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1339
|
+
() => probeBody({ ok: false, measured: true, accounts: [], error: cause }),
|
|
1340
|
+
));
|
|
1341
|
+
|
|
1342
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1343
|
+
|
|
1344
|
+
const record = log.byEvent('refusal.google-unhealthy');
|
|
1345
|
+
expect(record).toBeDefined();
|
|
1346
|
+
expect(record!.reason).toBe(cause);
|
|
1347
|
+
expect(log.emitted.find((e) => e.line.includes('refusal.google-unhealthy'))!.method).toBe('error');
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
it('reports an unhealthy layer even when the runner names no cause', async () => {
|
|
1351
|
+
const log = captureLog();
|
|
1352
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1353
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1354
|
+
() => probeBody({ ok: false, measured: true, accounts: [] }),
|
|
1355
|
+
));
|
|
1356
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1357
|
+
expect(log.byEvent('refusal.google-unhealthy')!.reason).toMatch(/no cause/);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('REVIEW DEFECT: a probe that could not RUN never becomes "the credential is refused"', async () => {
|
|
1361
|
+
// This is the record that decides an incident: `refusal.google-unhealthy`
|
|
1362
|
+
// means "Google refused the call AND the live check agrees the credential is
|
|
1363
|
+
// refused". Three of the runner's causes are facts about the probe — it
|
|
1364
|
+
// timed out, it could not be run at all (no `gog` on PATH, no
|
|
1365
|
+
// `credentials.json` on the volume), its output could not be parsed — and
|
|
1366
|
+
// filing those here would tell an operator the refresh token was dead on
|
|
1367
|
+
// evidence nobody gathered. Worse, it silently disables `refusal.google-ok`,
|
|
1368
|
+
// the ONE record that can prove or kill the narrow theory.
|
|
1369
|
+
for (const error of [
|
|
1370
|
+
'the Google probe timed out before gog answered',
|
|
1371
|
+
'the Google probe could not be run',
|
|
1372
|
+
'gog auth list --check returned unrecognized output',
|
|
1373
|
+
'gog did not report token validity',
|
|
1374
|
+
'gog reported an account it explicitly did not check',
|
|
1375
|
+
]) {
|
|
1376
|
+
const log = captureLog();
|
|
1377
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1378
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1379
|
+
() => probeBody({ ok: false, measured: false, accounts: [], error }),
|
|
1380
|
+
));
|
|
1381
|
+
|
|
1382
|
+
// The caller's error is untouched, as always.
|
|
1383
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/Google API error \(401/);
|
|
1384
|
+
|
|
1385
|
+
expect(log.byEvent('refusal.google-unhealthy')).toBeUndefined();
|
|
1386
|
+
const record = log.byEvent('refusal.google-unmeasured');
|
|
1387
|
+
expect(record).toBeDefined();
|
|
1388
|
+
expect(record!.reason).toBe(error);
|
|
1389
|
+
expect(log.emitted.find((e) => e.line.includes('refusal.google-unmeasured'))!.method).toBe('warn');
|
|
1390
|
+
vi.restoreAllMocks();
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it('will not claim the credential is refused from a runner that never said it measured', async () => {
|
|
1395
|
+
const log = captureLog();
|
|
1396
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1397
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1398
|
+
() => probeBody({ ok: false, accounts: [], error: 'something went wrong' }),
|
|
1399
|
+
));
|
|
1400
|
+
|
|
1401
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1402
|
+
|
|
1403
|
+
expect(log.byEvent('refusal.google-unhealthy')).toBeUndefined();
|
|
1404
|
+
expect(log.byEvent('refusal.google-unmeasured')!.reason).toContain('something went wrong');
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
it('never turns a probe that could not run into a claim about Google', async () => {
|
|
1408
|
+
// A runner deployed before /health/google existed answers 404. "I could not
|
|
1409
|
+
// ask" must never be filed as "Google said no" — that is the defect this
|
|
1410
|
+
// whole branch exists to delete, with the alarm merely inverted.
|
|
1411
|
+
const log = captureLog();
|
|
1412
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1413
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1414
|
+
() => probeBody({ error: 'not found' }, 404),
|
|
1415
|
+
));
|
|
1416
|
+
|
|
1417
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1418
|
+
|
|
1419
|
+
const record = log.byEvent('refusal.google-unmeasured');
|
|
1420
|
+
expect(record).toBeDefined();
|
|
1421
|
+
expect(record!.reason).toMatch(/404/);
|
|
1422
|
+
// NOT an error: the absence of a measurement is not evidence of anything.
|
|
1423
|
+
expect(log.emitted.find((e) => e.line.includes('refusal.google-unmeasured'))!.method).toBe('warn');
|
|
1424
|
+
expect(log.byEvent('refusal.google-unhealthy')).toBeUndefined();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it('survives a probe that rejects, and still lets the original error through', async () => {
|
|
1428
|
+
const log = captureLog();
|
|
1429
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1430
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1431
|
+
() => { throw new Error('network down'); },
|
|
1432
|
+
));
|
|
1433
|
+
|
|
1434
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/Google API error \(401/);
|
|
1435
|
+
expect(log.byEvent('refusal.google-unmeasured')!.reason).toMatch(/network down/);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it('survives a probe that rejects with a non-Error', async () => {
|
|
1439
|
+
const log = captureLog();
|
|
1440
|
+
vi.stubGlobal('fetch', routedFetch(
|
|
1441
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1442
|
+
() => { throw 'nope'; },
|
|
1443
|
+
));
|
|
1444
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1445
|
+
expect(log.byEvent('refusal.google-unmeasured')!.reason).toBe('nope');
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it('does not ask a question gog already answered: invalid_grant is not probed', async () => {
|
|
1449
|
+
// gog said the grant is dead. Spending a Google API call to be told the same
|
|
1450
|
+
// thing buys nothing, and this is the COMMON failure — the 7-day cliff — so
|
|
1451
|
+
// probing it would be the one case that costs the most and learns the least.
|
|
1452
|
+
const log = captureLog();
|
|
1453
|
+
const fetchMock = routedFetch(
|
|
1454
|
+
() => gogFailed(`${GOOGLE_401_STDERR}\noauth2: "invalid_grant"`),
|
|
1455
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1456
|
+
);
|
|
1457
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1458
|
+
|
|
1459
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/invalid_grant/);
|
|
1460
|
+
expect(probeCalls(fetchMock)).toBe(0);
|
|
1461
|
+
expect(log.byEvent('grant.dead')).toBeDefined();
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it('MUST NOT REGRESS: a runner transport failure is never probed for Google health', async () => {
|
|
1465
|
+
// The runner's own 401 is about OUR bearer. gog never ran and no Google
|
|
1466
|
+
// credential was read, so asking Google anything here would re-create the
|
|
1467
|
+
// exact misattribution 2.21.1 fixed — one layer down, in the log.
|
|
1468
|
+
const fetchMock = routedFetch(
|
|
1469
|
+
() => ({ ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) }),
|
|
1470
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1471
|
+
);
|
|
1472
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1473
|
+
captureLog();
|
|
1474
|
+
|
|
1475
|
+
const err = await makeFlyExecutor(ENDPOINT, KEY)(READ, {}).catch((e: unknown) => e);
|
|
1476
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
1477
|
+
expect(probeCalls(fetchMock)).toBe(0);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('MUST NOT REGRESS: an ordinary gog failure is never probed', async () => {
|
|
1481
|
+
const fetchMock = routedFetch(
|
|
1482
|
+
() => gogFailed('row 401 is outside the sheet grid'),
|
|
1483
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1484
|
+
);
|
|
1485
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1486
|
+
captureLog();
|
|
1487
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/outside the sheet grid/);
|
|
1488
|
+
expect(probeCalls(fetchMock)).toBe(0);
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
it('does not probe when the call carried a token of ours — that path repairs itself', async () => {
|
|
1492
|
+
const fetchMock = routedFetch(
|
|
1493
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1494
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1495
|
+
);
|
|
1496
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1497
|
+
captureLog();
|
|
1498
|
+
const readToken = Object.assign(vi.fn(async () => 'ya29.stale'), {
|
|
1499
|
+
invalidate: vi.fn(async () => true),
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY, readToken)(READ, {})).rejects.toThrow(/401/);
|
|
1503
|
+
// Two /run attempts (the replay), and no probe: the eviction already
|
|
1504
|
+
// answered the question the probe would ask.
|
|
1505
|
+
expect(runCalls(fetchMock)).toBe(2);
|
|
1506
|
+
expect(probeCalls(fetchMock)).toBe(0);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('will not spend the caller’s remaining deadline on a diagnostic', async () => {
|
|
1510
|
+
// The probe shares the tool call's ONE deadline. Below the floor it could
|
|
1511
|
+
// only abort, and an abort here would delay the caller's real error for
|
|
1512
|
+
// nothing.
|
|
1513
|
+
const log = captureLog();
|
|
1514
|
+
const fetchMock = routedFetch(
|
|
1515
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1516
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1517
|
+
);
|
|
1518
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1519
|
+
|
|
1520
|
+
// opts.timeout 0 leaves only DEADLINE_GRACE_MS. The clock is read once to
|
|
1521
|
+
// fix the deadline and once by the probe; making the second read land a
|
|
1522
|
+
// minute later is exactly "the first attempt ran long".
|
|
1523
|
+
const start = Date.now();
|
|
1524
|
+
let reads = 0;
|
|
1525
|
+
vi.spyOn(Date, 'now').mockImplementation(() => (reads++ === 0 ? start : start + 60_000));
|
|
1526
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, { timeout: 0 })).rejects.toThrow(/401/);
|
|
1527
|
+
|
|
1528
|
+
expect(probeCalls(fetchMock)).toBe(0);
|
|
1529
|
+
expect(log.byEvent('refusal.google-unmeasured')!.reason).toMatch(/deadline/);
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it('probes at most once per interval, so a retry loop cannot storm the backend', async () => {
|
|
1533
|
+
// /health/google spawns a real gog and takes the keyring's exclusive flock
|
|
1534
|
+
// (gogcli v0.34.1: auth list → ListTokens → withWriteLock → unix.LOCK_EX;
|
|
1535
|
+
// see the sourced note on PROBE_INTERVAL_MS). A model retrying a dead call
|
|
1536
|
+
// must not turn one diagnostic into a queue.
|
|
1537
|
+
const log = captureLog();
|
|
1538
|
+
const fetchMock = routedFetch(
|
|
1539
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1540
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1541
|
+
);
|
|
1542
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1543
|
+
|
|
1544
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
1545
|
+
await expect(exec(READ, {})).rejects.toThrow(/401/);
|
|
1546
|
+
await expect(exec(READ, {})).rejects.toThrow(/401/);
|
|
1547
|
+
|
|
1548
|
+
expect(runCalls(fetchMock)).toBe(2);
|
|
1549
|
+
expect(probeCalls(fetchMock)).toBe(1);
|
|
1550
|
+
expect(log.byEvent('refusal.google-unmeasured')!.reason).toMatch(/attempted recently/);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// End to end through the REAL run() and the REAL diagnose(), because the claim
|
|
1554
|
+
// that matters most about this whole feature is a NEGATIVE one: the caller's
|
|
1555
|
+
// result is byte-identical whether the probe ran or not. This also re-pins the
|
|
1556
|
+
// #250 shape — `Google API error (401 authError)` still reaching the auth
|
|
1557
|
+
// hint — with the probe in the loop.
|
|
1558
|
+
it('MUST NOT REGRESS: the caller’s diagnosed result is identical with the probe in the loop', async () => {
|
|
1559
|
+
captureLog();
|
|
1560
|
+
const withProbe = routedFetch(
|
|
1561
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1562
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1563
|
+
);
|
|
1564
|
+
vi.stubGlobal('fetch', withProbe);
|
|
1565
|
+
const probed = await runExecutor.run({ executor: makeFlyExecutor(ENDPOINT, KEY) }, () =>
|
|
1566
|
+
runOrDiagnose(['gmail', 'search', 'q'], {}),
|
|
1567
|
+
);
|
|
1568
|
+
|
|
1569
|
+
// The same failure through an executor whose probe is skipped outright
|
|
1570
|
+
// (gog said invalid_grant is a different error, so use the throttle: a
|
|
1571
|
+
// second call on the same executor never probes).
|
|
1572
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
1573
|
+
const twice = routedFetch(
|
|
1574
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1575
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1576
|
+
);
|
|
1577
|
+
vi.stubGlobal('fetch', twice);
|
|
1578
|
+
await runExecutor.run({ executor: exec }, () => runOrDiagnose(['gmail', 'search', 'q'], {}));
|
|
1579
|
+
const unprobed = await runExecutor.run({ executor: exec }, () =>
|
|
1580
|
+
runOrDiagnose(['gmail', 'search', 'q'], {}),
|
|
1581
|
+
);
|
|
1582
|
+
|
|
1583
|
+
expect(probed.isError).toBe(true);
|
|
1584
|
+
expect(probed.content[0].text).toContain('Google API error (401 authError)');
|
|
1585
|
+
expect(probed.content[0].text).toContain('gog_auth_add');
|
|
1586
|
+
// The whole point: the probe is invisible to the caller.
|
|
1587
|
+
expect(unprobed.content[0].text).toBe(probed.content[0].text);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
it('MUST NOT CLAIM: the throttle line never asserts a measurement that did not happen', async () => {
|
|
1591
|
+
// The whole thesis of this branch is that a log line may not claim health it
|
|
1592
|
+
// did not measure. The throttle is the one place that rule can be broken
|
|
1593
|
+
// from the inside: `lastProbeAt` is stamped BEFORE the fetch and is not
|
|
1594
|
+
// reset when the probe comes back with no verdict, so every refusal for the
|
|
1595
|
+
// next PROBE_INTERVAL_MS is explained by a sentence about the previous
|
|
1596
|
+
// probe. If that sentence says the layer "was measured", it is describing a
|
|
1597
|
+
// measurement that never occurred — here, a runner too old to have
|
|
1598
|
+
// /health/google at all.
|
|
1599
|
+
//
|
|
1600
|
+
// Stamping before the await is correct and stays: it is what stops two
|
|
1601
|
+
// overlapping refusals from both spawning a probe, and the backend cost the
|
|
1602
|
+
// throttle protects was paid whether or not a verdict came back. So the
|
|
1603
|
+
// sentence is what has to be true, not the timestamp.
|
|
1604
|
+
const log = captureLog();
|
|
1605
|
+
const fetchMock = routedFetch(
|
|
1606
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1607
|
+
() => probeBody({ error: 'not found' }, 404),
|
|
1608
|
+
);
|
|
1609
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1610
|
+
|
|
1611
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
1612
|
+
await expect(exec(READ, {})).rejects.toThrow(/401/);
|
|
1613
|
+
await expect(exec(READ, {})).rejects.toThrow(/401/);
|
|
1614
|
+
|
|
1615
|
+
// One probe attempted, and it produced no verdict about Google.
|
|
1616
|
+
expect(probeCalls(fetchMock)).toBe(1);
|
|
1617
|
+
const unmeasured = log
|
|
1618
|
+
.records()
|
|
1619
|
+
.filter((r) => r.event === 'refusal.google-unmeasured')
|
|
1620
|
+
.map((r) => r.reason as string);
|
|
1621
|
+
expect(unmeasured).toHaveLength(2);
|
|
1622
|
+
expect(unmeasured[0]).toMatch(/did not answer the Google probe \(HTTP 404\)/);
|
|
1623
|
+
|
|
1624
|
+
// The throttled line: it must say a probe was ATTEMPTED, never that the
|
|
1625
|
+
// Google layer was measured.
|
|
1626
|
+
expect(unmeasured[1]).toMatch(/attempted recently/);
|
|
1627
|
+
expect(unmeasured[1]).not.toMatch(/measured recently|was measured|re-measured/);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it('throttles per executor, so one session cannot silence another', async () => {
|
|
1631
|
+
const log = captureLog();
|
|
1632
|
+
const fetchMock = routedFetch(
|
|
1633
|
+
() => gogFailed(GOOGLE_401_STDERR),
|
|
1634
|
+
() => probeBody({ ok: true, measured: true, accounts: [] }),
|
|
1635
|
+
);
|
|
1636
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1637
|
+
|
|
1638
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1639
|
+
await expect(makeFlyExecutor(ENDPOINT, KEY)(READ, {})).rejects.toThrow(/401/);
|
|
1640
|
+
|
|
1641
|
+
expect(probeCalls(fetchMock)).toBe(2);
|
|
1642
|
+
expect(log.byEvent('refusal.google-unmeasured')).toBeUndefined();
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readGoogleProbe } from '../src/google-probe.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* REVIEW DEFECT: "the probe could not run" was filed as "Google refused".
|
|
6
|
+
*
|
|
7
|
+
* The runner collapses every non-healthy outcome into `ok:false`, and BOTH
|
|
8
|
+
* connector call sites used to branch on `ok === true` alone. Three of the
|
|
9
|
+
* runner's causes are facts about the probe, not about Google — it timed out, it
|
|
10
|
+
* could not be run (`gog` missing from PATH, `credentials.json` missing from the
|
|
11
|
+
* volume), or its output could not be parsed — and each of those became an
|
|
12
|
+
* error-level `connect.google-unhealthy` / `refusal.google-unhealthy`, whose
|
|
13
|
+
* documented meaning is "Google was asked and refused". An operator grepping
|
|
14
|
+
* event names would close the incident on evidence nobody gathered.
|
|
15
|
+
*
|
|
16
|
+
* The runner now states `measured` explicitly. This module is the ONE place that
|
|
17
|
+
* reads it, precisely because the defect was two call sites making the same
|
|
18
|
+
* judgement separately.
|
|
19
|
+
*/
|
|
20
|
+
describe('readGoogleProbe', () => {
|
|
21
|
+
it('reads a healthy answer as ok', () => {
|
|
22
|
+
expect(readGoogleProbe({ ok: true, measured: true })).toEqual({ kind: 'ok' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reads a measured refusal as unhealthy, carrying the runner’s cause', () => {
|
|
26
|
+
expect(readGoogleProbe({ ok: false, measured: true, error: 'invalid_grant: …' })).toEqual({
|
|
27
|
+
kind: 'unhealthy',
|
|
28
|
+
reason: 'invalid_grant: …',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('says so plainly when a measured refusal names no cause', () => {
|
|
33
|
+
const verdict = readGoogleProbe({ ok: false, measured: true });
|
|
34
|
+
expect(verdict.kind).toBe('unhealthy');
|
|
35
|
+
expect(verdict.reason).toMatch(/no cause/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads measured:false as UNMEASURED however loudly the cause reads', () => {
|
|
39
|
+
// The regression under test. Every one of these used to be `unhealthy`.
|
|
40
|
+
for (const error of [
|
|
41
|
+
'the Google probe timed out before gog answered',
|
|
42
|
+
'the Google probe could not be run',
|
|
43
|
+
'gog auth list --check returned unrecognized output',
|
|
44
|
+
'gog did not report token validity',
|
|
45
|
+
'gog reported an account it explicitly did not check',
|
|
46
|
+
]) {
|
|
47
|
+
expect(readGoogleProbe({ ok: false, measured: false, error })).toEqual({
|
|
48
|
+
kind: 'unmeasured',
|
|
49
|
+
reason: error,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses to claim HEALTH from a runner that never said it measured', () => {
|
|
55
|
+
// The row this table was missing, and the last place `ok` outranked
|
|
56
|
+
// `measured`. A bare `ok:true` used to return {kind:'ok'}, which on the
|
|
57
|
+
// refusal path is `refusal.google-ok` — the record documented as "the one
|
|
58
|
+
// record that means we cannot explain this", logged at error level, and
|
|
59
|
+
// held up as the only evidence that could justify automatic recovery on the
|
|
60
|
+
// hosted path. Raising it from a measurement nobody took is this branch's
|
|
61
|
+
// founding defect surviving in the module written to delete it. `ok:true`
|
|
62
|
+
// is not self-licensing: it is a health claim, and a health claim needs a
|
|
63
|
+
// measurement behind it — exactly what the runner's README asserts when it
|
|
64
|
+
// says `ok:true` always travels with `measured:true`.
|
|
65
|
+
const verdict = readGoogleProbe({ ok: true });
|
|
66
|
+
expect(verdict.kind).toBe('unmeasured');
|
|
67
|
+
expect(verdict.reason).toMatch(/did not report whether/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('carries the runner’s cause when a bare ok:true also names one', () => {
|
|
71
|
+
const verdict = readGoogleProbe({ ok: true, error: 'something went wrong' });
|
|
72
|
+
expect(verdict.kind).toBe('unmeasured');
|
|
73
|
+
expect(verdict.reason).toContain('something went wrong');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('never claims health from an incoherent ok:true + measured:false', () => {
|
|
77
|
+
// `measured` is read FIRST, so the field that can only under-claim wins.
|
|
78
|
+
const verdict = readGoogleProbe({ ok: true, measured: false });
|
|
79
|
+
expect(verdict.kind).toBe('unmeasured');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('describes an unmeasured answer that names no cause', () => {
|
|
83
|
+
const verdict = readGoogleProbe({ ok: false, measured: false });
|
|
84
|
+
expect(verdict.kind).toBe('unmeasured');
|
|
85
|
+
expect(verdict.reason).toMatch(/could not measure/i);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses to claim ill health from a runner that never said it measured', () => {
|
|
89
|
+
// Silence is not a measurement. An `ok:false` with no `measured` field is
|
|
90
|
+
// read as unmeasured — under-claiming, the only safe direction.
|
|
91
|
+
const verdict = readGoogleProbe({ ok: false, error: 'something went wrong' });
|
|
92
|
+
expect(verdict.kind).toBe('unmeasured');
|
|
93
|
+
expect(verdict.reason).toContain('something went wrong');
|
|
94
|
+
expect(verdict.reason).toMatch(/did not report whether/i);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reads a body with no fields at all, and a null body, as unmeasured', () => {
|
|
98
|
+
expect(readGoogleProbe({}).kind).toBe('unmeasured');
|
|
99
|
+
expect(readGoogleProbe(null).kind).toBe('unmeasured');
|
|
100
|
+
expect(readGoogleProbe(undefined).kind).toBe('unmeasured');
|
|
101
|
+
expect(readGoogleProbe('a proxy error page').kind).toBe('unmeasured');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ignores non-boolean field types rather than trusting them', () => {
|
|
105
|
+
// JSON from a proxy, or a future runner, may put anything here.
|
|
106
|
+
expect(readGoogleProbe({ ok: 'true', measured: 'true' }).kind).toBe('unmeasured');
|
|
107
|
+
expect(readGoogleProbe({ ok: 1, measured: 1 }).kind).toBe('unmeasured');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('never carries a non-string cause into a log line', () => {
|
|
111
|
+
const verdict = readGoogleProbe({ ok: false, measured: true, error: { nested: 'object' } });
|
|
112
|
+
expect(verdict.kind).toBe('unhealthy');
|
|
113
|
+
expect(typeof verdict.reason).toBe('string');
|
|
114
|
+
expect(verdict.reason).toMatch(/no cause/i);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/tests/timestamps.test.ts
CHANGED
|
@@ -316,3 +316,55 @@ describe('normalizeTimestamps', () => {
|
|
|
316
316
|
expect(out.date).toMatch(/[+-]\d{2}:\d{2}$/);
|
|
317
317
|
});
|
|
318
318
|
});
|
|
319
|
+
|
|
320
|
+
// gog 0.35.0 (#946) adds `internalDateIso` to Gmail message AND thread
|
|
321
|
+
// listings: Gmail's own internalDate rendered RFC3339 with a real offset. It is
|
|
322
|
+
// separately sourced from the neighbouring `date` (a naive reconstruction of
|
|
323
|
+
// the sender's Date header), so the two can legitimately disagree — but only
|
|
324
|
+
// `internalDateIso` carries its own zone, which makes it the field a machine
|
|
325
|
+
// consumer should read.
|
|
326
|
+
describe('internalDateIso (gog >= 0.35.0 Gmail listings)', () => {
|
|
327
|
+
it('gains a display sibling and survives a DISPLAY_TZ unlike its own offset', () => {
|
|
328
|
+
const payload = JSON.stringify({
|
|
329
|
+
messages: [{ id: 'm1', date: '2026-07-28 03:36', internalDateIso: '2026-07-28T03:36:12-04:00' }],
|
|
330
|
+
});
|
|
331
|
+
const pt = JSON.parse(normalizeTimestamps(payload, 'America/Los_Angeles', 'UTC'));
|
|
332
|
+
const row = pt.messages[0];
|
|
333
|
+
// Re-rendered in the display zone, same instant, offset still explicit.
|
|
334
|
+
expect(row.internalDateIso).toBe('2026-07-28T00:36:12-07:00');
|
|
335
|
+
expect(Date.parse(row.internalDateIso)).toBe(Date.parse('2026-07-28T03:36:12-04:00'));
|
|
336
|
+
expect(row.internalDateIsoDisplay).toContain('Tue, Jul 28');
|
|
337
|
+
expect(isNaiveTimestamp(row.internalDateIso)).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('keeps sub-second precision when gog emits milliseconds', () => {
|
|
341
|
+
const out = JSON.parse(normalizeTimestamps(
|
|
342
|
+
JSON.stringify({ internalDateIso: '2026-07-28T03:36:12.250-04:00' }), ET,
|
|
343
|
+
));
|
|
344
|
+
expect(out.internalDateIso).toBe('2026-07-28T03:36:12.250-04:00');
|
|
345
|
+
expect(out.internalDateIsoDisplay).toContain('Tue, Jul 28');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// The trap: `date` is gog re-formatting the sender header into GOG_TIMEZONE
|
|
349
|
+
// with no offset, so the wrapper has to re-read it in that zone. Only
|
|
350
|
+
// `internalDateIso` is self-describing, and the two are allowed to disagree.
|
|
351
|
+
it('is trusted verbatim while the naive sibling is read in GOG_TIMEZONE', () => {
|
|
352
|
+
const out = JSON.parse(normalizeTimestamps(JSON.stringify({
|
|
353
|
+
date: '2026-07-28 03:36',
|
|
354
|
+
internalDateIso: '2026-07-27T23:36:12-04:00',
|
|
355
|
+
}), ET, 'UTC'));
|
|
356
|
+
expect(out.internalDateIso).toBe('2026-07-27T23:36:12-04:00');
|
|
357
|
+
expect(out.date).toBe('2026-07-27T23:36:00-04:00');
|
|
358
|
+
expect(out.dateDisplay).toContain('Jul 27');
|
|
359
|
+
expect(out.internalDateIsoDisplay).toContain('Jul 27');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// A thread listing carries the same field (gmail_thread_search_helpers.go).
|
|
363
|
+
it('normalizes the field on thread listings too', () => {
|
|
364
|
+
const out = JSON.parse(normalizeTimestamps(
|
|
365
|
+
JSON.stringify({ threads: [{ id: 't1', internalDateIso: '2026-07-28T03:36:12Z' }] }), ET,
|
|
366
|
+
));
|
|
367
|
+
expect(out.threads[0].internalDateIso).toBe('2026-07-27T23:36:12-04:00');
|
|
368
|
+
expect(out.threads[0].internalDateIsoDisplay).toContain('Mon, Jul 27');
|
|
369
|
+
});
|
|
370
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -69,6 +69,20 @@ describe('gog_auth_status', () => {
|
|
|
69
69
|
const result = await harness.callTool('gog_auth_status', {});
|
|
70
70
|
expect(result.content[0].text).toBe('Error: Status failed');
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
it('does not present itself as a health check', async () => {
|
|
74
|
+
// `gog auth status` prints the keyring backend and where the credential
|
|
75
|
+
// files live. It contacts nothing. Named "status" next to a connector whose
|
|
76
|
+
// UI says "connected", it reads as the answer to "is my auth OK?" — which is
|
|
77
|
+
// the question only gog_auth_health can answer.
|
|
78
|
+
const harness = await setupHandlers();
|
|
79
|
+
const { tools } = await harness.client.listTools();
|
|
80
|
+
const desc = tools.find((t) => t.name === 'gog_auth_status')!.description!;
|
|
81
|
+
|
|
82
|
+
expect(desc).toMatch(/does not contact Google/i);
|
|
83
|
+
expect(desc).toContain('gog_auth_health');
|
|
84
|
+
await harness.close();
|
|
85
|
+
});
|
|
72
86
|
});
|
|
73
87
|
|
|
74
88
|
describe('gog_auth_services', () => {
|
|
@@ -127,6 +141,20 @@ describe('gog_auth_add', () => {
|
|
|
127
141
|
});
|
|
128
142
|
|
|
129
143
|
describe('gog_auth_health', () => {
|
|
144
|
+
it('names itself as the only live measurement of the Google layer', async () => {
|
|
145
|
+
// DEFECT 1's wording half: the hosted connector's "connected" / "refreshed"
|
|
146
|
+
// is an OAuth refresh inside OAUTH_KV that contacts neither Fly nor Google.
|
|
147
|
+
// Nothing in this repo can change that word, so the tool that DOES measure
|
|
148
|
+
// has to say that it is the one that does.
|
|
149
|
+
const harness = await setupHandlers();
|
|
150
|
+
const { tools } = await harness.client.listTools();
|
|
151
|
+
const desc = tools.find((t) => t.name === 'gog_auth_health')!.description!;
|
|
152
|
+
|
|
153
|
+
expect(desc).toMatch(/connected|refreshed/i);
|
|
154
|
+
expect(desc).toMatch(/only|nothing else/i);
|
|
155
|
+
await harness.close();
|
|
156
|
+
});
|
|
157
|
+
|
|
130
158
|
const CHECK_JSON = JSON.stringify({
|
|
131
159
|
accounts: [
|
|
132
160
|
{ email: 'chris.c.hall@gmail.com', created_at: '2026-07-17T15:08:39Z', valid: true },
|