gogcli-mcp 2.20.0 → 2.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +434 -66
- package/dist/lib.js +396 -47
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/auth-log.ts +147 -0
- package/src/connector-runtime.ts +585 -80
- package/src/google-token.ts +391 -0
- package/src/remote-runner.ts +8 -1
- package/src/runner.ts +67 -2
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +94 -7
- package/src/worker.ts +12 -1
- package/tests/auth-log.test.ts +508 -0
- package/tests/connector-runtime.test.ts +675 -2
- package/tests/google-token.test.ts +425 -0
- package/tests/runner.test.ts +21 -1
- package/tests/sdk-single-copy.test.ts +48 -0
- package/tests/tools/auth-401-context.test.ts +42 -0
- package/tests/tools/auth-401-shapes.test.ts +50 -0
- package/tests/tools/auth.test.ts +21 -0
- package/tests/tools/utils.test.ts +99 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
import { run } from '../src/runner.js';
|
|
2
|
+
import { run, runExecutor } from '../src/runner.js';
|
|
3
3
|
import type { GogArg, GogExecutor, GogFileArg } from '../src/runner.js';
|
|
4
|
-
import { makeFlyExecutor, wrapServer } from '../src/connector-runtime.js';
|
|
4
|
+
import { makeFlyExecutor, wrapServer, RunnerTransportError, isRunnerTransportError } from '../src/connector-runtime.js';
|
|
5
|
+
import { runOrDiagnose } from '../src/tools/utils.js';
|
|
6
|
+
import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
|
|
5
7
|
|
|
6
8
|
afterEach(() => {
|
|
7
9
|
vi.unstubAllEnvs();
|
|
@@ -523,3 +525,674 @@ describe('makeFlyExecutor', () => {
|
|
|
523
525
|
await expect(exec(['x'], {})).rejects.toThrow(/gog failed on the runner/i);
|
|
524
526
|
});
|
|
525
527
|
});
|
|
528
|
+
|
|
529
|
+
// A failure the RUNNER authored — its own bearer check, its own request
|
|
530
|
+
// validation, its own drain — never reached gog and never showed a credential
|
|
531
|
+
// to Google. Those failures must be distinguishable by TYPE, because the layer
|
|
532
|
+
// that diagnoses them (tools/utils.ts) can only otherwise guess from prose, and
|
|
533
|
+
// guessing is what turned the runner's bare `unauthorized` body into "your
|
|
534
|
+
// Google sign-in expired, re-authorize".
|
|
535
|
+
describe('makeFlyExecutor runner-authored transport failures', () => {
|
|
536
|
+
const ENDPOINT = 'https://gogcli-gog-runner.fly.dev';
|
|
537
|
+
const KEY = 'k';
|
|
538
|
+
|
|
539
|
+
function stubStatus(status: number, body?: unknown) {
|
|
540
|
+
vi.stubGlobal(
|
|
541
|
+
'fetch',
|
|
542
|
+
vi.fn(async () => ({
|
|
543
|
+
ok: false,
|
|
544
|
+
status,
|
|
545
|
+
json: async () => {
|
|
546
|
+
if (body === undefined) throw new Error('not json');
|
|
547
|
+
return body;
|
|
548
|
+
},
|
|
549
|
+
})),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function thrownBy(status: number, body?: unknown): Promise<unknown> {
|
|
554
|
+
stubStatus(status, body);
|
|
555
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
556
|
+
return exec(['gmail', 'search', 'q'], {}).catch((e: unknown) => e);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The exact body fly-gog-runner/server.mjs sends when its bearer check fails
|
|
560
|
+
// (server.mjs:450 /health, :460 /run) — nothing to do with Google.
|
|
561
|
+
const RUNNER_401 = { error: 'unauthorized' };
|
|
562
|
+
|
|
563
|
+
it('types the runner bearer rejection as transport auth, not a gog failure', async () => {
|
|
564
|
+
const err = await thrownBy(401, RUNNER_401);
|
|
565
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
566
|
+
expect((err as RunnerTransportError).kind).toBe('transport-auth');
|
|
567
|
+
expect((err as RunnerTransportError).status).toBe(401);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('names the real cause — the runner key mismatch — and never the Google account', async () => {
|
|
571
|
+
const err = (await thrownBy(401, RUNNER_401)) as Error;
|
|
572
|
+
expect(err.message).toContain('GOG_RUNNER_KEY');
|
|
573
|
+
expect(err.message).toContain('RUNNER_KEY');
|
|
574
|
+
expect(err.message).not.toMatch(/gog_auth_add/i);
|
|
575
|
+
// The runner's own body is the single word "unauthorized". Repeating it is
|
|
576
|
+
// what fed DEFINITE_AUTH_PATTERN in tools/utils.ts; the status digits can do
|
|
577
|
+
// the same whenever a status word sits near them. Neither may appear, so that
|
|
578
|
+
// even if the TYPE is lost at some future boundary the prose cannot be
|
|
579
|
+
// misread as Google's.
|
|
580
|
+
expect(err.message).not.toMatch(/unauthorized/i);
|
|
581
|
+
expect(err.message).not.toMatch(/\b401\b/);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('types the runner request-validation 400s as transport-request, keeping their detail', async () => {
|
|
585
|
+
for (const detail of [
|
|
586
|
+
'request body too large',
|
|
587
|
+
'failed to read request body',
|
|
588
|
+
'body must be valid JSON',
|
|
589
|
+
'args must be an array',
|
|
590
|
+
'accessToken must not contain whitespace or control characters',
|
|
591
|
+
]) {
|
|
592
|
+
const err = await thrownBy(400, { error: detail });
|
|
593
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
594
|
+
expect((err as RunnerTransportError).kind).toBe('transport-request');
|
|
595
|
+
expect((err as RunnerTransportError).status).toBe(400);
|
|
596
|
+
expect((err as Error).message).toBe(detail);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('still says something when a 400 body is unreadable', async () => {
|
|
601
|
+
const err = await thrownBy(400);
|
|
602
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
603
|
+
expect((err as RunnerTransportError).kind).toBe('transport-request');
|
|
604
|
+
expect((err as Error).message).toMatch(/gog-runner rejected the request/i);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('types the drain 503 as retryable transport, message unchanged', async () => {
|
|
608
|
+
const err = await thrownBy(503, { error: 'gog-runner is shutting down', retryable: true });
|
|
609
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
610
|
+
expect((err as RunnerTransportError).kind).toBe('transport-retryable');
|
|
611
|
+
expect((err as RunnerTransportError).status).toBe(503);
|
|
612
|
+
expect((err as Error).message).toBe('gog-runner is restarting; retry this call. gog-runner is shutting down');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('types a retryable-flagged 500 (a materialization failure) as retryable transport', async () => {
|
|
616
|
+
const err = await thrownBy(500, {
|
|
617
|
+
error: 'failed to write a file arg to disk: ENOSPC: no space left on device',
|
|
618
|
+
retryable: true,
|
|
619
|
+
});
|
|
620
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
621
|
+
expect((err as RunnerTransportError).kind).toBe('transport-retryable');
|
|
622
|
+
expect((err as RunnerTransportError).status).toBe(500);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('types a bodiless gateway failure as retryable transport', async () => {
|
|
626
|
+
const err = await thrownBy(502);
|
|
627
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
628
|
+
expect((err as RunnerTransportError).kind).toBe('transport-retryable');
|
|
629
|
+
expect((err as RunnerTransportError).status).toBe(502);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('types the client-side deadline as retryable transport with no status', async () => {
|
|
633
|
+
vi.stubGlobal(
|
|
634
|
+
'fetch',
|
|
635
|
+
vi.fn(async () => {
|
|
636
|
+
throw Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' });
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
640
|
+
const err = await exec(['x'], {}).catch((e: unknown) => e);
|
|
641
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
642
|
+
expect((err as RunnerTransportError).kind).toBe('transport-retryable');
|
|
643
|
+
expect((err as RunnerTransportError).status).toBeUndefined();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// The other half of the contract: an error that carries gog's/Google's OWN
|
|
647
|
+
// words must stay untyped, so the prose classifier still gets to read it.
|
|
648
|
+
it('leaves a 422 (gog ran and failed) untyped, for the prose classifier', async () => {
|
|
649
|
+
const err = await thrownBy(422, { error: 'gog failed', stderr: 'Error 401: invalid_grant' });
|
|
650
|
+
expect(err).toBeInstanceOf(Error);
|
|
651
|
+
expect(isRunnerTransportError(err)).toBe(false);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('leaves a detail-bearing non-2xx untyped', async () => {
|
|
655
|
+
const err = await thrownBy(502, { error: 'gog exited with code 1', stderr: 'bad flag' });
|
|
656
|
+
expect(isRunnerTransportError(err)).toBe(false);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('recognises only branded errors', () => {
|
|
660
|
+
expect(isRunnerTransportError(new Error('unauthorized'))).toBe(false);
|
|
661
|
+
expect(isRunnerTransportError('unauthorized')).toBe(false);
|
|
662
|
+
expect(isRunnerTransportError(new RunnerTransportError('x', 'transport-auth', 401))).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// The whole chain, end to end, with the REAL executor, the REAL run() and the
|
|
666
|
+
// REAL diagnose(): the incident was a user told all session to re-authorize a
|
|
667
|
+
// Google account that was never asked for a credential.
|
|
668
|
+
it('does not tell the caller to re-authorize Google when the RUNNER rejected our bearer', async () => {
|
|
669
|
+
vi.stubEnv('GOG_ACCOUNT', '');
|
|
670
|
+
vi.stubEnv('GOG_READONLY', '');
|
|
671
|
+
stubStatus(401, RUNNER_401);
|
|
672
|
+
const executor = makeFlyExecutor(ENDPOINT, KEY);
|
|
673
|
+
const result = await runExecutor.run({ executor }, () =>
|
|
674
|
+
runOrDiagnose(['sheets', 'get', 'A1'], {}),
|
|
675
|
+
);
|
|
676
|
+
const text = result.content[0].text as string;
|
|
677
|
+
expect(result.isError).toBe(true);
|
|
678
|
+
expect(text).not.toMatch(/gog_auth_add/);
|
|
679
|
+
expect(text).not.toMatch(/re-authorize the account/i);
|
|
680
|
+
expect(text).toContain('GOG_RUNNER_KEY');
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// A Google 401 on the ACCESS token and a dead REFRESH token are opposite
|
|
685
|
+
// failures that used to be told to the user identically.
|
|
686
|
+
//
|
|
687
|
+
// * The access token is ours to replace. Google rejecting it means "mint
|
|
688
|
+
// another and try again" — automatic, no human, no re-authorization. Before
|
|
689
|
+
// this, the rejected token stayed cached for the rest of its nominal hour
|
|
690
|
+
// and NOTHING retried, so every call in that window failed the same way and
|
|
691
|
+
// only reconnecting (a fresh isolate, an empty cache) appeared to help.
|
|
692
|
+
// * invalid_grant means the refresh token is gone. No re-mint is possible and
|
|
693
|
+
// a human must re-authorize. Retrying that is a loop against a credential
|
|
694
|
+
// that can never work.
|
|
695
|
+
//
|
|
696
|
+
// So the re-mint is gated hard: gog must actually have run (422), the token must
|
|
697
|
+
// be one WE supplied, gog's own stderr must say Google rejected it, the call
|
|
698
|
+
// must be safe to replay, and our cache must still hold exactly that token.
|
|
699
|
+
describe('makeFlyExecutor re-mints a rejected access token and replays once', () => {
|
|
700
|
+
const ENDPOINT = 'https://gogcli-gog-runner.fly.dev';
|
|
701
|
+
const KEY = 'k';
|
|
702
|
+
|
|
703
|
+
// gog v0.34.1's real words when the access token it was handed is rejected.
|
|
704
|
+
const GOOGLE_401_STDERR =
|
|
705
|
+
'Note: Using direct access token (expires in ~1 hour; no auto-refresh)\n' +
|
|
706
|
+
'Google API error (401 authError): Request had invalid authentication credentials. ' +
|
|
707
|
+
'Expected OAuth 2 access token, login cookie or other valid authentication credential.';
|
|
708
|
+
|
|
709
|
+
// What the executor really receives: run()'s assembleArgs puts the global
|
|
710
|
+
// flags in front of the service and subcommand.
|
|
711
|
+
const READ = ['--json', '--color=never', '--no-input', 'gmail', 'search', 'q'];
|
|
712
|
+
const WRITE = ['--json', '--color=never', '--no-input', 'gmail', 'send', '--to', 'a@b.c'];
|
|
713
|
+
|
|
714
|
+
function gogFailed(stderr: string) {
|
|
715
|
+
return {
|
|
716
|
+
ok: false,
|
|
717
|
+
status: 422,
|
|
718
|
+
// `error` is Node's execFile message, which embeds the whole command line
|
|
719
|
+
// AND stderr — see the command-line test below for why that matters.
|
|
720
|
+
json: async () => ({
|
|
721
|
+
error: `Command failed: gog gmail search q\n${stderr}`,
|
|
722
|
+
stderr,
|
|
723
|
+
retryable: false,
|
|
724
|
+
}),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function ok(stdout: string) {
|
|
728
|
+
return { ok: true, json: async () => ({ stdout }) };
|
|
729
|
+
}
|
|
730
|
+
function bodyOf(fetchMock: { mock: { calls: unknown[][] } }, i: number): Record<string, unknown> {
|
|
731
|
+
const [, init] = fetchMock.mock.calls[i] as [string, RequestInit];
|
|
732
|
+
return JSON.parse(init.body as string) as Record<string, unknown>;
|
|
733
|
+
}
|
|
734
|
+
/** A token source that hands out `tokens` in order and can be invalidated. */
|
|
735
|
+
function source(tokens: (string | undefined)[], evicted = true) {
|
|
736
|
+
const fn = vi.fn(async () => tokens.shift());
|
|
737
|
+
return Object.assign(fn, { invalidate: vi.fn(async () => evicted) });
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
it('mints a new token and replays the call, invisibly to the caller', async () => {
|
|
741
|
+
const fetchMock = vi
|
|
742
|
+
.fn()
|
|
743
|
+
.mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR))
|
|
744
|
+
.mockResolvedValueOnce(ok('thread json'));
|
|
745
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
746
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
747
|
+
|
|
748
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
749
|
+
await expect(exec(READ, {})).resolves.toBe('thread json');
|
|
750
|
+
|
|
751
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
752
|
+
expect(bodyOf(fetchMock, 0).accessToken).toBe('ya29.stale');
|
|
753
|
+
expect(bodyOf(fetchMock, 1).accessToken).toBe('ya29.fresh');
|
|
754
|
+
// Evicted by VALUE — only the token that was actually rejected.
|
|
755
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
756
|
+
expect(readToken.invalidate).toHaveBeenCalledWith('ya29.stale');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('replays exactly once, so a genuinely dead credential cannot loop', async () => {
|
|
760
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
761
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
762
|
+
const readToken = source(['ya29.stale', 'ya29.fresh', 'ya29.third']);
|
|
763
|
+
|
|
764
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
765
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error \(401/);
|
|
766
|
+
|
|
767
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
768
|
+
// Two evictions, one per token Google refused — the replay's included. The
|
|
769
|
+
// ATTEMPT count is what "exactly once" is about, and it is still two.
|
|
770
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(2);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('does not replay when gog reported invalid_grant — only a human can fix that', async () => {
|
|
774
|
+
// The stderr deliberately matches BOTH shapes, so this pins the exclusion
|
|
775
|
+
// rather than the absence of a 401.
|
|
776
|
+
const fetchMock = vi.fn(async () => gogFailed(`${GOOGLE_401_STDERR}\noauth2: "invalid_grant"`));
|
|
777
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
778
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
779
|
+
|
|
780
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
781
|
+
await expect(exec(READ, {})).rejects.toThrow(/invalid_grant/);
|
|
782
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
783
|
+
expect(readToken.invalidate).not.toHaveBeenCalled();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('does not replay a call that supplied no token of ours', async () => {
|
|
787
|
+
// Nothing was minted, so there is nothing to re-mint: gog used whatever
|
|
788
|
+
// identity the backend volume holds, and only an operator can change that.
|
|
789
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
790
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
791
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY);
|
|
792
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
793
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('does not replay when the token source cannot invalidate', async () => {
|
|
797
|
+
// A bare `() => token` (the #230 direct-token wiring) has no cache behind
|
|
798
|
+
// it, so replaying would re-send the identical rejected token.
|
|
799
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
800
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
801
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, () => 'ya29.direct');
|
|
802
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
803
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('does not replay when the cache no longer held the rejected token', async () => {
|
|
807
|
+
// Another caller already refreshed it; the token we would send is the one
|
|
808
|
+
// that is already in use, so a replay proves nothing.
|
|
809
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
810
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
811
|
+
const readToken = source(['ya29.stale', 'ya29.fresh'], false);
|
|
812
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
813
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
814
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
815
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// A subcommand is only a leaf VERB some of the time. `gog tasks lists` is a
|
|
819
|
+
// NAMESPACE — `lists list` reads, `lists create` writes — and the allow-list
|
|
820
|
+
// is consulted with the namespace word, never the verb under it. So a
|
|
821
|
+
// namespace word in the set hands the replay to every child it will ever
|
|
822
|
+
// grow, including the ones that write.
|
|
823
|
+
//
|
|
824
|
+
// `tasks lists create <title> ...` is real in gog v0.34.1 today, and reachable
|
|
825
|
+
// without any new gog: `gog_tasks_run({subcommand: 'lists', args: ['create',
|
|
826
|
+
// 'A', 'B']})` assembles exactly this argv. It is variadic, so one invocation
|
|
827
|
+
// makes N Google calls and a 401 on the second means the first already landed
|
|
828
|
+
// — the precise double-apply the write rule exists to prevent.
|
|
829
|
+
it('does not replay `tasks lists create`, a WRITE under a namespace-shaped word', async () => {
|
|
830
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
831
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
832
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
833
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
834
|
+
await expect(
|
|
835
|
+
exec(['--json', '--color=never', '--no-input', 'tasks', 'lists', 'create', 'A', 'B'], {}),
|
|
836
|
+
).rejects.toThrow(/Google API error/);
|
|
837
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
838
|
+
// The eviction still happens — it runs before the allow-list is consulted.
|
|
839
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// The invariant the allow-list's comment states, pinned as behaviour so the
|
|
843
|
+
// comment is no longer the only thing enforcing it. Every word here is a gog
|
|
844
|
+
// NAMESPACE that already has, or can grow, a mutating child; none of them may
|
|
845
|
+
// ever earn a replay, whichever verb follows.
|
|
846
|
+
it.each([
|
|
847
|
+
['tasks', 'lists'],
|
|
848
|
+
['gmail', 'labels'],
|
|
849
|
+
['gmail', 'drafts'],
|
|
850
|
+
['gmail', 'filters'],
|
|
851
|
+
['gmail', 'sendas'],
|
|
852
|
+
['drive', 'permissions'],
|
|
853
|
+
['drive', 'revisions'],
|
|
854
|
+
['docs', 'comments'],
|
|
855
|
+
['docs', 'replies'],
|
|
856
|
+
])('never replays the namespace word %s %s, whatever verb follows it', async (service, namespace) => {
|
|
857
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
858
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
859
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
860
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
861
|
+
await expect(
|
|
862
|
+
exec(['--json', '--color=never', '--no-input', service, namespace, 'create', 'x'], {}),
|
|
863
|
+
).rejects.toThrow(/Google API error/);
|
|
864
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
865
|
+
// The eviction is unaffected: it runs before the allow-list is consulted.
|
|
866
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// Symmetry with the rule the whole fix is built on: a token Google has
|
|
870
|
+
// refused must not stay cached, and the very same 401 refused the replay's
|
|
871
|
+
// token. This is NOT a round-trip saving — measured through the real chain
|
|
872
|
+
// under a sustained non-invalid_grant refusal, a steady-state call costs two
|
|
873
|
+
// /run round-trips either way, and this eviction adds a mint (2 rather than
|
|
874
|
+
// 1) by emptying a cache the next call would have hit. What it buys is a
|
|
875
|
+
// bound on how long a KNOWN-REFUSED token can be served: left cached it is
|
|
876
|
+
// re-served for the rest of its nominal hour, and a write — which gets the
|
|
877
|
+
// eviction and no replay — would be sent with it and fail on contact.
|
|
878
|
+
it('evicts the replayed token too when Google refuses that one as well', async () => {
|
|
879
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
880
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
881
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
882
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
883
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
884
|
+
expect(readToken.invalidate.mock.calls).toEqual([['ya29.stale'], ['ya29.fresh']]);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
// The other half of that rule, and the one that keeps this branch honest.
|
|
888
|
+
//
|
|
889
|
+
// A replay can fail without Google ever seeing the token: the Machine starts
|
|
890
|
+
// draining between the two attempts, the client-side deadline fires, the
|
|
891
|
+
// runner's own bearer is rotated mid-call. Evicting on THOSE would throw away
|
|
892
|
+
// a token nothing has refused and — worse — emit `token.evicted` with
|
|
893
|
+
// "Google rejected this access token", which is a lie about a service that
|
|
894
|
+
// was never consulted. Misattributing a runner-side failure to Google is the
|
|
895
|
+
// exact defect this branch exists to remove; re-introducing it in the log
|
|
896
|
+
// would just move it from the user's screen to the operator's query.
|
|
897
|
+
it('does not evict the replayed token when the replay never reached Google', async () => {
|
|
898
|
+
const fetchMock = vi
|
|
899
|
+
.fn()
|
|
900
|
+
.mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR))
|
|
901
|
+
// The runner drains between the two attempts — a transport failure, not
|
|
902
|
+
// a verdict on the freshly minted token.
|
|
903
|
+
.mockResolvedValueOnce({
|
|
904
|
+
ok: false,
|
|
905
|
+
status: 503,
|
|
906
|
+
json: async () => ({ error: 'gog-runner is shutting down', retryable: true }),
|
|
907
|
+
});
|
|
908
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
909
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
910
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
911
|
+
const err = await exec(READ, {}).catch((e: unknown) => e);
|
|
912
|
+
// The replay's own error reaches the caller, and here that is the RIGHT
|
|
913
|
+
// one to surface: "the runner is restarting, retry" is actionable, where
|
|
914
|
+
// re-raising the superseded Google 401 would send the user off to
|
|
915
|
+
// re-authorize an account that is fine.
|
|
916
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
917
|
+
expect((err as RunnerTransportError).kind).toBe('transport-retryable');
|
|
918
|
+
// Only the token Google actually refused was dropped. `ya29.fresh` stays
|
|
919
|
+
// cached: it is unproven, not refused, and the next call may well succeed
|
|
920
|
+
// with it once the new Machine is up.
|
|
921
|
+
expect(readToken.invalidate.mock.calls).toEqual([['ya29.stale']]);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// The mint is a first-class error surface on this branch, so the error it
|
|
925
|
+
// raises has to reach the caller with the SAME specificity a gog-authored
|
|
926
|
+
// invalid_grant gets: the 7-day Testing-mode cause and the headless re-auth
|
|
927
|
+
// pair. It only does if the message carries the literal `invalid_grant`,
|
|
928
|
+
// which is what tools/utils.ts keys the richer hint on.
|
|
929
|
+
it('gives a mint-path invalid_grant the full re-auth guidance, not the generic hint', async () => {
|
|
930
|
+
vi.stubEnv('GOG_ACCOUNT', '');
|
|
931
|
+
vi.stubEnv('GOG_READONLY', '');
|
|
932
|
+
clearAccessTokenCache();
|
|
933
|
+
const fetchMock = vi.fn(async (url: unknown) => {
|
|
934
|
+
if (String(url).includes('oauth2.googleapis.com')) {
|
|
935
|
+
return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 });
|
|
936
|
+
}
|
|
937
|
+
return gogFailed('unreachable');
|
|
938
|
+
});
|
|
939
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
940
|
+
const executor = makeFlyExecutor(
|
|
941
|
+
ENDPOINT,
|
|
942
|
+
KEY,
|
|
943
|
+
makeAccessTokenSource({
|
|
944
|
+
GOG_CLIENT_ID: 'cid',
|
|
945
|
+
GOG_CLIENT_SECRET: 'cs',
|
|
946
|
+
GOG_REFRESH_TOKEN: 'rt-dead',
|
|
947
|
+
}),
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const result = await runExecutor.run({ executor }, () =>
|
|
951
|
+
runOrDiagnose(['gmail', 'search', 'q'], {}),
|
|
952
|
+
);
|
|
953
|
+
expect(result.isError).toBe(true);
|
|
954
|
+
const text = result.content[0].text as string;
|
|
955
|
+
// Text unique to INVALID_GRANT_HINT — the durable fix. The generic
|
|
956
|
+
// AUTH_HINT (which is what a message omitting `invalid_grant` earns) says
|
|
957
|
+
// only "Authentication may have expired".
|
|
958
|
+
expect(text).toContain('publish the OAuth consent screen to "In production"');
|
|
959
|
+
expect(text).not.toContain('Authentication may have expired');
|
|
960
|
+
clearAccessTokenCache();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it('does not replay a WRITE, but still evicts the token Google rejected', async () => {
|
|
964
|
+
// The one case an automatic replay must never take. A `gog gmail send` that
|
|
965
|
+
// failed AFTER the message went out would send it twice.
|
|
966
|
+
//
|
|
967
|
+
// The EVICTION is a different question, and the write rule must not answer
|
|
968
|
+
// it. Refusing to evict is what leaves Google's rejected token in the cache
|
|
969
|
+
// for the rest of its nominal hour, so every following call — write or read
|
|
970
|
+
// — re-sends it and fails identically until the isolate is replaced. That
|
|
971
|
+
// is DEFECT 2 itself, and it is the half a write path used to keep.
|
|
972
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
973
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
974
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
975
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
976
|
+
await expect(exec(WRITE, {})).rejects.toThrow(/Google API error/);
|
|
977
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
978
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
979
|
+
expect(readToken.invalidate).toHaveBeenCalledWith('ya29.stale');
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('evicts for a READ whose subcommand is outside the allow-list', async () => {
|
|
983
|
+
// `gog gmail labels list` arrives here as the subcommand `labels`, which is
|
|
984
|
+
// deliberately not in READ_ONLY_SUBCOMMANDS (a later `labels create` would
|
|
985
|
+
// inherit the replay). Costing that call its replay is the intended price;
|
|
986
|
+
// costing it the eviction would poison every call after it.
|
|
987
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
988
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
989
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
990
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
991
|
+
await expect(
|
|
992
|
+
exec(['--json', '--color=never', '--no-input', 'gmail', 'labels', 'list'], {}),
|
|
993
|
+
).rejects.toThrow(/Google API error/);
|
|
994
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
995
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('lets a second WRITE mint a fresh token, through the REAL token source', async () => {
|
|
999
|
+
// The incident, reproduced end to end with nothing stubbed but the network:
|
|
1000
|
+
// two consecutive `gog gmail send` calls after Google has rejected the
|
|
1001
|
+
// cached token. Before the eviction was moved ahead of the write rule, BOTH
|
|
1002
|
+
// shipped `ya29.t1` and both failed, for up to ~58 minutes, and only a
|
|
1003
|
+
// reconnect helped.
|
|
1004
|
+
clearAccessTokenCache();
|
|
1005
|
+
let minted = 0;
|
|
1006
|
+
const fetchMock = vi.fn(async (url: unknown) => {
|
|
1007
|
+
if (String(url).includes('oauth2.googleapis.com')) {
|
|
1008
|
+
return new Response(
|
|
1009
|
+
JSON.stringify({ access_token: `ya29.t${(minted += 1)}`, expires_in: 3600 }),
|
|
1010
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return gogFailed(GOOGLE_401_STDERR);
|
|
1014
|
+
});
|
|
1015
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1016
|
+
|
|
1017
|
+
const exec = makeFlyExecutor(
|
|
1018
|
+
ENDPOINT,
|
|
1019
|
+
KEY,
|
|
1020
|
+
makeAccessTokenSource({
|
|
1021
|
+
GOG_CLIENT_ID: 'cid',
|
|
1022
|
+
GOG_CLIENT_SECRET: 'cs',
|
|
1023
|
+
GOG_REFRESH_TOKEN: 'rt-1',
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
await expect(exec(WRITE, {})).rejects.toThrow(/Google API error/);
|
|
1027
|
+
await expect(exec(WRITE, {})).rejects.toThrow(/Google API error/);
|
|
1028
|
+
|
|
1029
|
+
const runBodies = fetchMock.mock.calls
|
|
1030
|
+
.filter(([url]) => !String(url).includes('oauth2.googleapis.com'))
|
|
1031
|
+
.map(([, init]) => JSON.parse((init as RequestInit).body as string) as { accessToken: string });
|
|
1032
|
+
expect(runBodies.map((b) => b.accessToken)).toEqual(['ya29.t1', 'ya29.t2']);
|
|
1033
|
+
clearAccessTokenCache();
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('replays with what is LEFT of the deadline, not a second full one', async () => {
|
|
1037
|
+
// 30s default + 5s grace is one tool call's whole budget. Handing the
|
|
1038
|
+
// replay a fresh copy of it makes the worst case ~70s of wall clock, which
|
|
1039
|
+
// can outlast the MCP client's own request timeout and turn a self-healing
|
|
1040
|
+
// read into a client-side hang.
|
|
1041
|
+
const budgets: number[] = [];
|
|
1042
|
+
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
|
|
1043
|
+
vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => {
|
|
1044
|
+
budgets.push(ms);
|
|
1045
|
+
return realTimeout(ms);
|
|
1046
|
+
});
|
|
1047
|
+
const fetchMock = vi
|
|
1048
|
+
.fn()
|
|
1049
|
+
.mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR))
|
|
1050
|
+
.mockResolvedValueOnce(ok('threads'));
|
|
1051
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1052
|
+
const base = Date.now();
|
|
1053
|
+
// The clock is read once to fix the deadline, then once more to size the
|
|
1054
|
+
// replay; 5s of the budget is gone by then.
|
|
1055
|
+
vi.spyOn(Date, 'now').mockReturnValueOnce(base).mockReturnValue(base + 5_000);
|
|
1056
|
+
|
|
1057
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale', 'ya29.fresh']));
|
|
1058
|
+
await expect(exec(READ, {})).resolves.toBe('threads');
|
|
1059
|
+
expect(budgets).toEqual([35_000, 30_000]);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it('skips the replay when the first attempt used the whole deadline', async () => {
|
|
1063
|
+
// With no budget left, a replay can only end in an abort, and that
|
|
1064
|
+
// TimeoutError would REPLACE gog's own 401 — trading an actionable error
|
|
1065
|
+
// for an opaque one. The eviction is the durable half of the repair and it
|
|
1066
|
+
// has already happened, so the caller's own next call is the fresh one.
|
|
1067
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
1068
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1069
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1070
|
+
const base = Date.now();
|
|
1071
|
+
vi.spyOn(Date, 'now').mockReturnValueOnce(base).mockReturnValue(base + 34_900);
|
|
1072
|
+
|
|
1073
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1074
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
1075
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1076
|
+
expect(readToken.invalidate).toHaveBeenCalledTimes(1);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('finds the subcommand past --account, whose value is not a subcommand', async () => {
|
|
1080
|
+
const fetchMock = vi
|
|
1081
|
+
.fn()
|
|
1082
|
+
.mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR))
|
|
1083
|
+
.mockResolvedValueOnce(ok('[]'));
|
|
1084
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1085
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1086
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1087
|
+
await expect(
|
|
1088
|
+
exec(['--json', '--color=never', '--no-input', '--account', 'me@example.com', 'drive', 'ls'], {}),
|
|
1089
|
+
).resolves.toBe('[]');
|
|
1090
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('does not replay an invocation with no subcommand to judge', async () => {
|
|
1094
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
1095
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1096
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1097
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1098
|
+
await expect(exec(['--json', '--color=never', 'gmail'], {})).rejects.toThrow(/Google API error/);
|
|
1099
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('reads gog stderr, not the command line the runner echoed back', async () => {
|
|
1103
|
+
// execFile's message embeds the whole argv, so a caller's own text lands in
|
|
1104
|
+
// `error`. Classifying on that would let `--subject "invoice 401"` trigger a
|
|
1105
|
+
// replay of a call that failed for an unrelated reason — and if that call
|
|
1106
|
+
// were a write, replay it after it had partly applied.
|
|
1107
|
+
const fetchMock = vi.fn(async () => ({
|
|
1108
|
+
ok: false,
|
|
1109
|
+
status: 422,
|
|
1110
|
+
json: async () => ({
|
|
1111
|
+
error: 'Command failed: gog gmail search "Google API error (401 authError)"\nno results',
|
|
1112
|
+
stderr: 'no results',
|
|
1113
|
+
retryable: false,
|
|
1114
|
+
}),
|
|
1115
|
+
}));
|
|
1116
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1117
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1118
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1119
|
+
await expect(exec(READ, {})).rejects.toThrow(/no results/);
|
|
1120
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1121
|
+
expect(readToken.invalidate).not.toHaveBeenCalled();
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it('does not replay a gog failure that has nothing to do with auth', async () => {
|
|
1125
|
+
const fetchMock = vi.fn(async () => gogFailed('invalid attachment id'));
|
|
1126
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1127
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1128
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1129
|
+
await expect(exec(READ, {})).rejects.toThrow(/invalid attachment id/);
|
|
1130
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
it('surfaces the original failure when the re-mint yields no token', async () => {
|
|
1134
|
+
// Sending the request without a token would run it as the BACKEND's
|
|
1135
|
+
// identity and hand this caller someone else's mailbox — never that.
|
|
1136
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
1137
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1138
|
+
const readToken = source(['ya29.stale', undefined]);
|
|
1139
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1140
|
+
await expect(exec(READ, {})).rejects.toThrow(/Google API error/);
|
|
1141
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it('surfaces the re-mint failure, which is the more actionable one', async () => {
|
|
1145
|
+
// Evicting revealed that the refresh token is dead too. That message names
|
|
1146
|
+
// the real repair (re-authorize); gog's 401 does not.
|
|
1147
|
+
const fetchMock = vi.fn(async () => gogFailed(GOOGLE_401_STDERR));
|
|
1148
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1149
|
+
const readToken = Object.assign(
|
|
1150
|
+
vi
|
|
1151
|
+
.fn<() => Promise<string | undefined>>()
|
|
1152
|
+
.mockResolvedValueOnce('ya29.stale')
|
|
1153
|
+
.mockRejectedValueOnce(new Error('the stored refresh token has expired or been revoked')),
|
|
1154
|
+
{ invalidate: vi.fn(async () => true) },
|
|
1155
|
+
);
|
|
1156
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1157
|
+
await expect(exec(READ, {})).rejects.toThrow(/refresh token has expired or been revoked/);
|
|
1158
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('never re-mints for the RUNNER\'s own 401, which never reached Google', async () => {
|
|
1162
|
+
// The defect-1 failure. Its body is the bare word "unauthorized" and no
|
|
1163
|
+
// Google credential was even read, so minting a new one is pure waste — and
|
|
1164
|
+
// replaying would double every call during a key mismatch.
|
|
1165
|
+
const fetchMock = vi.fn(async () => ({
|
|
1166
|
+
ok: false,
|
|
1167
|
+
status: 401,
|
|
1168
|
+
json: async () => ({ error: 'unauthorized' }),
|
|
1169
|
+
}));
|
|
1170
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1171
|
+
const readToken = source(['ya29.stale', 'ya29.fresh']);
|
|
1172
|
+
const exec = makeFlyExecutor(ENDPOINT, KEY, readToken);
|
|
1173
|
+
const err = await exec(READ, {}).catch((e: unknown) => e);
|
|
1174
|
+
expect(isRunnerTransportError(err)).toBe(true);
|
|
1175
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1176
|
+
expect(readToken.invalidate).not.toHaveBeenCalled();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
// End to end through the REAL run() and the REAL diagnose(): the user-visible
|
|
1180
|
+
// property is that a stale access token produces no error and no advice at
|
|
1181
|
+
// all, because it healed itself.
|
|
1182
|
+
it('turns a stale-token failure into a plain success, with no re-auth advice', async () => {
|
|
1183
|
+
vi.stubEnv('GOG_ACCOUNT', '');
|
|
1184
|
+
vi.stubEnv('GOG_READONLY', '');
|
|
1185
|
+
const fetchMock = vi
|
|
1186
|
+
.fn()
|
|
1187
|
+
.mockResolvedValueOnce(gogFailed(GOOGLE_401_STDERR))
|
|
1188
|
+
.mockResolvedValueOnce(ok('{"threads":[]}'));
|
|
1189
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
1190
|
+
const executor = makeFlyExecutor(ENDPOINT, KEY, source(['ya29.stale', 'ya29.fresh']));
|
|
1191
|
+
|
|
1192
|
+
const result = await runExecutor.run({ executor }, () =>
|
|
1193
|
+
runOrDiagnose(['gmail', 'search', 'q'], {}),
|
|
1194
|
+
);
|
|
1195
|
+
expect(result.isError).toBeFalsy();
|
|
1196
|
+
expect(result.content[0].text).toBe('{"threads":[]}');
|
|
1197
|
+
});
|
|
1198
|
+
});
|