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.
@@ -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
+ });