pi-lean-dimension 0.5.0 → 0.6.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.
Files changed (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -513,6 +513,62 @@ describe("oauth init — interactive wizard", () => {
513
513
  ).toBe(true);
514
514
  });
515
515
 
516
+ it("wizard paste Esc → recovery input: a typed corrected URI restarts in-flow — the seven wizard answers are not re-asked", async () => {
517
+ writeSecret("wzdr2.invalid", "client_id", "MY_CLIENT");
518
+ const CUSTOM = "http://localhost:5173/callback";
519
+ const seenBodies: string[] = [];
520
+ stubTokenEndpoint((_url, init) => {
521
+ seenBodies.push(String(init.body));
522
+ return tokenResponse({ access_token: "WZDR2", expires_in: 3600 });
523
+ });
524
+ const m = makeCtx({ hasUI: true });
525
+ const { inputs } = m;
526
+ // grant (select) → redirect URI (default) → tokenUrl, authorizeUrl,
527
+ // scopes (empty) + client-id / client-secret (omit) — seven answers.
528
+ m.selects.push("authorization_code", "client_id", OMIT);
529
+ m.inputs.push("", TOKEN_URL, AUTHORIZE_URL, "");
530
+ let pasteCalls = 0;
531
+ let recoveryCalls = 0;
532
+ (m.ctx.ui.input as ReturnType<typeof vi.fn>).mockImplementation(
533
+ async (title: string) => {
534
+ const queued = inputs.shift();
535
+ if (queued !== undefined) return queued; // the wizard answers
536
+ if (title.includes("Redirect URI — Enter to keep")) {
537
+ recoveryCalls++;
538
+ return CUSTOM; // corrected URI → in-flow restart
539
+ }
540
+ pasteCalls++;
541
+ const pending = readPendingFlow(
542
+ "wzdr2.invalid",
543
+ "authorization_code",
544
+ TOKEN_URL,
545
+ );
546
+ if (pasteCalls === 1) return undefined; // Esc at the first paste
547
+ return `${CUSTOM}?code=CB&state=${pending?.state}`;
548
+ },
549
+ );
550
+ await handleOauthSubcommand("init wzdr2.invalid", m.ctx);
551
+ // The mint completed with the corrected URI.
552
+ expect(
553
+ readToken("wzdr2.invalid", "authorization_code", TOKEN_URL)?.accessToken,
554
+ ).toBe("WZDR2");
555
+ expect(
556
+ seenBodies.some((b) =>
557
+ b.includes(`redirect_uri=${encodeURIComponent(CUSTOM)}`),
558
+ ),
559
+ ).toBe(true);
560
+ // Only the paste was re-asked (Esc at paste → recovery → paste);
561
+ // the wizard's 3 selects + 4 inputs were never repeated.
562
+ expect(recoveryCalls).toBe(1);
563
+ expect(pasteCalls).toBe(2);
564
+ expect((m.ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
565
+ 3,
566
+ );
567
+ expect((m.ctx.ui.input as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
568
+ 7, // 4 wizard + 2 pastes + 1 recovery
569
+ );
570
+ });
571
+
516
572
  it("wizard redirect-URI prompt: Esc cancels, nothing provisioned", async () => {
517
573
  writeSecret("wzdc.invalid", "client_id", "MY_CLIENT");
518
574
  const m = makeCtx({ hasUI: true });
@@ -527,6 +583,45 @@ describe("oauth init — interactive wizard", () => {
527
583
  expect(readToken("wzdc.invalid", "authorization_code", TOKEN_URL)).toBeNull();
528
584
  });
529
585
 
586
+ it("wizard paste cancel: after Esc at the paste and Esc at the recovery input, the wizard cancel message still prints with all seven answers intact", async () => {
587
+ writeSecret("wzdp.invalid", "client_id", "MY_CLIENT");
588
+ const m = makeCtx({ hasUI: true });
589
+ const { inputs } = m;
590
+ // grant (select) → redirect URI (empty = default) → tokenUrl,
591
+ // authorizeUrl, scopes (empty) + client-id / client-secret (omit) —
592
+ // the seven wizard answers. The queue is then exhausted, so the paste
593
+ // prompt gets undefined (Esc) and so does the recovery input.
594
+ m.selects.push("authorization_code", "client_id", OMIT);
595
+ m.inputs.push("", TOKEN_URL, AUTHORIZE_URL, "");
596
+ let restarts = 0;
597
+ (m.ctx.ui.input as ReturnType<typeof vi.fn>).mockImplementation(
598
+ async (title: string) => {
599
+ if (title.includes("Redirect URI — Enter to keep")) {
600
+ restarts++;
601
+ return undefined; // Esc at recovery → abort
602
+ }
603
+ return inputs.shift(); // wizard answers, then paste Esc (undefined)
604
+ },
605
+ );
606
+ await handleOauthSubcommand("init wzdp.invalid", m.ctx);
607
+ // The wizard's answers were consumed before the paste — the restart
608
+ // lives inside the flow, so nothing was re-asked after the Esc.
609
+ expect((m.ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
610
+ 3,
611
+ );
612
+ expect(inputs).toHaveLength(0);
613
+ expect(restarts).toBe(1);
614
+ // Prompt count: 4 wizard inputs + the paste + the recovery input = 6.
615
+ expect((m.ctx.ui.input as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(
616
+ 6,
617
+ );
618
+ expect(m.out()).toContain("Cancelled. Re-run /api oauth init wzdp.invalid");
619
+ expect(
620
+ readPendingFlow("wzdp.invalid", "authorization_code", TOKEN_URL),
621
+ ).not.toBeNull();
622
+ expect(readToken("wzdp.invalid", "authorization_code", TOKEN_URL)).toBeNull();
623
+ });
624
+
530
625
  it("wizard with no provisioned secrets aborts with the /api secrets nudge", async () => {
531
626
  const m = makeCtx({ hasUI: true });
532
627
  m.selects.push("client_credentials");
@@ -517,6 +517,169 @@ describe("mintAuthCodeToken", () => {
517
517
  expect(warned).toContain("state mismatch");
518
518
  });
519
519
 
520
+ // The recovery input (opened by Esc at the paste) is routed by TITLE —
521
+ // keying a double on pending-flow state would feed a synthetic paste into
522
+ // it and mask the restart path instead of exercising it.
523
+ const RECOVERY_TITLE = "Redirect URI — Enter to keep";
524
+
525
+ it.each([
526
+ ["empty Enter", ""],
527
+ // a prefilling client submits the default on plain Enter — same keep path
528
+ ["a re-typed identical URI", REDIRECT_URI],
529
+ ])(
530
+ "recovery input: %s keeps the flow — paste re-prompts, pending unchanged, already-issued code stays valid",
531
+ async (_label, keep) => {
532
+ const auth = makeAuthCodeAuth();
533
+ writeSecret("oauth.recovery", "client_id", "MY_CLIENT");
534
+ stubTokenEndpoint(() =>
535
+ tokenResponse({ access_token: "REPASTE", expires_in: 3600 }),
536
+ );
537
+ let firstState: string | undefined;
538
+ let recoveryCalls = 0;
539
+ const notify = vi.fn();
540
+ const ctx = {
541
+ hasUI: true,
542
+ ui: {
543
+ notify,
544
+ input: async (title: string) => {
545
+ if (title.includes(RECOVERY_TITLE)) {
546
+ recoveryCalls++;
547
+ // The current redirect URI is what's being kept or replaced.
548
+ expect(title).toContain(REDIRECT_URI);
549
+ return keep; // keep → paste again
550
+ }
551
+ const pending = readPendingFlow(
552
+ "oauth.recovery",
553
+ "authorization_code",
554
+ TOKEN_URL,
555
+ );
556
+ if (!firstState) {
557
+ firstState = pending?.state;
558
+ return undefined; // Esc at the paste → recovery input
559
+ }
560
+ // Same verifier/state as the first paste — nothing was rewritten.
561
+ expect(pending?.state).toBe(firstState);
562
+ return `http://127.0.0.1/callback?code=CB&state=${pending?.state}`;
563
+ },
564
+ },
565
+ } as any;
566
+
567
+ const token = await mintAuthCodeToken(auth, "oauth.recovery", ctx, {});
568
+ expect(token.accessToken).toBe("REPASTE");
569
+ expect(recoveryCalls).toBe(1);
570
+ // No second authorize-URL build — the keep re-prompts the paste only.
571
+ expect(
572
+ notify.mock.calls.filter((c: unknown[]) =>
573
+ String(c[0]).includes("code_challenge"),
574
+ ),
575
+ ).toHaveLength(1);
576
+ },
577
+ );
578
+
579
+ it("recovery input: a typed URI restarts the flow — regenerated pending, second authorize build, edited URI persisted", async () => {
580
+ const auth = makeAuthCodeAuth();
581
+ writeSecret("oauth.restart", "client_id", "MY_CLIENT");
582
+ const CUSTOM = "http://localhost:5173/callback";
583
+ const seenBodies: string[] = [];
584
+ stubTokenEndpoint((_url, init) => {
585
+ seenBodies.push(String(init.body));
586
+ return tokenResponse({ access_token: "RESTARTED", expires_in: 3600 });
587
+ });
588
+ let firstState: string | undefined;
589
+ const notify = vi.fn();
590
+ const ctx = {
591
+ hasUI: true,
592
+ ui: {
593
+ notify,
594
+ input: async (title: string) => {
595
+ if (title.includes(RECOVERY_TITLE)) {
596
+ expect(title).toContain(REDIRECT_URI);
597
+ return CUSTOM;
598
+ }
599
+ const pending = readPendingFlow(
600
+ "oauth.restart",
601
+ "authorization_code",
602
+ TOKEN_URL,
603
+ );
604
+ if (!firstState) {
605
+ firstState = pending?.state;
606
+ return undefined; // Esc at the paste → recovery input
607
+ }
608
+ // Restart regenerated the pending flow and persisted the edit.
609
+ expect(pending?.state).not.toBe(firstState);
610
+ expect(pending?.redirectUri).toBe(CUSTOM);
611
+ return `${CUSTOM}?code=CB&state=${pending?.state}`;
612
+ },
613
+ },
614
+ } as any;
615
+
616
+ const token = await mintAuthCodeToken(auth, "oauth.restart", ctx, {});
617
+ expect(token.accessToken).toBe("RESTARTED");
618
+ // Second authorize-URL build (the restart).
619
+ expect(
620
+ notify.mock.calls.filter((c: unknown[]) =>
621
+ String(c[0]).includes("code_challenge"),
622
+ ),
623
+ ).toHaveLength(2);
624
+ // The exchange used the edited URI (RFC 6749 §4.1.3).
625
+ expect(seenBodies[0]).toContain("redirect_uri=" + encodeURIComponent(CUSTOM));
626
+ });
627
+
628
+ it("recovery input: Esc there falls through to the existing cancel path; the pending flow survives", async () => {
629
+ const auth = makeAuthCodeAuth();
630
+ writeSecret("oauth.recesc", "client_id", "MY_CLIENT");
631
+ let recoveryCalls = 0;
632
+ const ctx = {
633
+ hasUI: true,
634
+ ui: {
635
+ notify: vi.fn(),
636
+ input: async (title: string) => {
637
+ if (title.includes(RECOVERY_TITLE)) recoveryCalls++;
638
+ return undefined; // Esc at the paste, then Esc at recovery
639
+ },
640
+ },
641
+ } as any;
642
+
643
+ await expect(
644
+ mintAuthCodeToken(auth, "oauth.recesc", ctx, {}),
645
+ ).rejects.toBeInstanceOf(OAuthTokenMissingError);
646
+ // The aborted Esc was the recovery input's, not the paste's.
647
+ expect(recoveryCalls).toBe(1);
648
+ expect(
649
+ readPendingFlow("oauth.recesc", "authorization_code", TOKEN_URL),
650
+ ).not.toBeNull();
651
+ });
652
+
653
+ it("recovery input: the failed-paste retry notify offers recovery options, not a plain cancel", async () => {
654
+ const auth = makeAuthCodeAuth();
655
+ writeSecret("oauth.reccopy", "client_id", "MY_CLIENT");
656
+ let firstPaste = true;
657
+ const ctx = {
658
+ hasUI: true,
659
+ ui: {
660
+ notify: vi.fn(),
661
+ input: async (title: string) => {
662
+ if (title.includes(RECOVERY_TITLE)) return undefined; // Esc → abort
663
+ if (firstPaste) {
664
+ firstPaste = false;
665
+ // A failed paste (wrong state) fires the retry notify.
666
+ return "http://127.0.0.1/callback?code=CB&state=WRONG";
667
+ }
668
+ return undefined; // Esc at the second paste
669
+ },
670
+ },
671
+ } as any;
672
+
673
+ await expect(
674
+ mintAuthCodeToken(auth, "oauth.reccopy", ctx, {}),
675
+ ).rejects.toBeInstanceOf(OAuthTokenMissingError);
676
+ const warned = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls
677
+ .map((c: unknown[]) => String(c[0]))
678
+ .join("\n");
679
+ expect(warned).toContain("escape for recovery options");
680
+ expect(warned).not.toContain("escape to cancel");
681
+ });
682
+
520
683
  it("pending flows are slot-isolated: two issuers on one domain don't consume each other's verifier", async () => {
521
684
  const TT2 = "https://other-issuer.example.com/oauth/token";
522
685
  const authA = makeAuthCodeAuth(); // tokenUrl = TOKEN_URL
@@ -71,6 +71,10 @@ function makeToolCtx(
71
71
  redirectResult?: string;
72
72
  /** If true, the paste input returns undefined (user cancels at the paste). */
73
73
  cancelPaste?: boolean;
74
+ /** Sequence of recovery-input results (the paste-loop Esc recovery;
75
+ * default undefined = Esc → abort — NEVER "", which would loop
76
+ * the cancelPaste test forever with no failing assertion). */
77
+ recoveryResults?: (string | undefined)[];
74
78
  } = {},
75
79
  ): {
76
80
  ctx: any;
@@ -107,18 +111,24 @@ function makeToolCtx(
107
111
  }),
108
112
  input: vi.fn(async (title: string) => {
109
113
  orders.push("input");
110
- // The redirect-URI edit is identified by title (it only fires when
111
- // "Change redirect URI" was selected); everything else is a paste.
112
- const isRedirectEdit = title.includes("Redirect URI for");
113
- const r = isRedirectEdit
114
- ? (opts.redirectResult ?? "")
115
- : opts.cancelPaste
114
+ // The paste-loop recovery input is identified by title (opened by Esc
115
+ // at the paste); the redirect-URI edit by "Redirect URI for" (it only
116
+ // fires when "Change redirect URI" was selected); everything else is
117
+ // a paste.
118
+ let r: string | undefined;
119
+ if (title.includes("Redirect URI — Enter to keep")) {
120
+ r = opts.recoveryResults?.shift() ?? undefined; // default: Esc → abort
121
+ } else if (title.includes("Redirect URI for")) {
122
+ r = opts.redirectResult ?? "";
123
+ } else {
124
+ r = opts.cancelPaste
116
125
  ? undefined
117
126
  : // Paste: simulate the user having authorized and pasting the
118
127
  // address-bar URL with the pending flow's state.
119
128
  `${REDIRECT_URI}?code=CB&state=${
120
129
  readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL)?.state
121
130
  }`;
131
+ }
122
132
  inputs.push([title, r]);
123
133
  return r;
124
134
  }),
@@ -550,6 +560,69 @@ describe("oauth-mint — authorization_code arm", () => {
550
560
  expect(fetchMock).not.toHaveBeenCalled();
551
561
  });
552
562
 
563
+ it("tripwire: Esc at the paste reaches the recovery input, whose default (undefined, never '') aborts", async () => {
564
+ writeSecret("mint.invalid", "client_id", "MY_CLIENT");
565
+ const m = makeToolCtx({ mode: "rpc", cancelPaste: true });
566
+ await expect(
567
+ oauthMintTool.execute(
568
+ "test",
569
+ {
570
+ domain: "mint.invalid",
571
+ grant: "authorization_code",
572
+ tokenUrl: TOKEN_URL,
573
+ clientId: "client_id",
574
+ authorizeUrl: AUTHORIZE_URL,
575
+ },
576
+ undefined,
577
+ undefined,
578
+ m.ctx,
579
+ ),
580
+ ).rejects.toThrow(/--code <redirect-url-or-code>/);
581
+ // The double routed by title: paste Esc → recovery input → its
582
+ // undefined default aborted. A "" default here would have looped the
583
+ // paste-undefined → recovery-"" → paste-undefined cycle forever.
584
+ const recovery = m.inputs.find(([t]) =>
585
+ t.includes("Redirect URI — Enter to keep"),
586
+ );
587
+ expect(recovery).toBeDefined();
588
+ expect(
589
+ readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL),
590
+ ).not.toBeNull();
591
+ });
592
+
593
+ it("recovery input: a garbage-typed URI restarts (regenerated flow) and a later Esc still aborts — no loop", async () => {
594
+ writeSecret("mint.invalid", "client_id", "MY_CLIENT");
595
+ const m = makeToolCtx({
596
+ mode: "rpc",
597
+ cancelPaste: true,
598
+ recoveryResults: ["not a uri at all"],
599
+ });
600
+ await expect(
601
+ oauthMintTool.execute(
602
+ "test",
603
+ {
604
+ domain: "mint.invalid",
605
+ grant: "authorization_code",
606
+ tokenUrl: TOKEN_URL,
607
+ clientId: "client_id",
608
+ authorizeUrl: AUTHORIZE_URL,
609
+ },
610
+ undefined,
611
+ undefined,
612
+ m.ctx,
613
+ ),
614
+ ).rejects.toThrow(/--code <redirect-url-or-code>/);
615
+ // Terminates: initial flow + one garbage restart, then the exhausted
616
+ // recovery queue (undefined) aborts.
617
+ const authorizeBuilds = m.inputs.filter(([t]) =>
618
+ t.includes("Open this URL in YOUR browser"),
619
+ );
620
+ expect(authorizeBuilds).toHaveLength(2);
621
+ expect(
622
+ readPendingFlow("mint.invalid", "authorization_code", TOKEN_URL),
623
+ ).not.toBeNull();
624
+ });
625
+
553
626
  it("auth-code redirect-URI edit: proposes the default, human can override inline; the override reaches the exchange", async () => {
554
627
  writeSecret("mint.invalid", "client_id", "MY_CLIENT");
555
628