diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index 51e2bec..fb8311e 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -197,7 +197,7 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 | | F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | | F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | -| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | +| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) | **Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):** diff --git a/docs/0-requirements.md b/docs/0-requirements.md index f1839e6..965a20d 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -197,7 +197,7 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | F7.8 | KV schema は自前設計: `client:{client_id}` / `web_auth_state:{state}` / `token:{access_token}` / `refresh:{refresh_token}` / `grant:{grant_id}`。device flow 時代の `device:` / `user_code:` キーは撤去 | | F7.9 | ローカルブリッジは authorize URL を platform 既定のブラウザで自動オープンする。Windows は `cmd /c start`、macOS は `open`、Linux は `xdg-open` を使う。オープン失敗は fatal にしない(stderr に警告を残し、URL は応答と stderr で伝える) | | F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | -| F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | +| F7.11 | ローカルブリッジは refresh の `invalid_grant` 後、共有 tokens file を直ちに再読込し、最大 1 秒の待機予算内で 50 ms 間隔に再読込する。この予算はファイル更新待ちの期限であり、既存の refresh 通信の所要時間上限は変更しない。新しい refresh_token の世代を見つけたら、有効な access token(期限なし、または期限まで 5 分超)があれば採用し、それ以外は refresh を最大 2 回試す。同じ世代は再試行しない。最初の refresh と回復処理は両 access-token 取得経路で単一のプロセス内 promise を共有し、成功・失敗の双方で解放する。更新なし・欠損・不正なファイル・本物の失効は有界に既存の全面認証/背景認証へ戻り、別種の refresh エラーでは再読込待機をしない。正常 refresh と有効なキャッシュには待機を追加しない。プロセス間 file lock とブラウザ認証共有化は対象外(#258。#242 の過去原因の確定を意味しない) | **Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):** diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index c479c66..04445ed 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -503,30 +503,65 @@ function isInvalidGrantError(err: unknown): boolean { return body.includes("invalid_grant"); } -/** - * RC1 fix: before giving up on a stale refresh_token, re-read the tokens file - * to see whether a concurrent process already rotated it. If the on-disk - * refresh_token differs from the one that just failed, adopt it and retry. - */ +// Only invalid_grant recovery pays this bounded disk-publication wait. +const REFRESH_RECOVERY_WAIT_MS = 1_000; +const REFRESH_RECOVERY_INTERVAL_MS = 50; +const REFRESH_RECOVERY_MAX_ATTEMPTS = 2; + async function tryRefreshViaDiskReread(previousRefreshToken: string): Promise { - const fresh = await loadTokens(); - if (!fresh || !fresh.refresh_token) return null; - if (fresh.refresh_token === previousRefreshToken) return null; - try { - const tokens = await refreshAccessToken(fresh.refresh_token); - return tokens; - } catch (err) { - console.error( - "[oauth] disk-reread refresh also failed:", - err instanceof Error ? err.message : err, - ); - return null; + const deadline = Date.now() + REFRESH_RECOVERY_WAIT_MS; + const attempted = new Set([previousRefreshToken]); + let attempts = 0; + while (true) { + const fresh = await loadTokens(); + if (fresh && fresh.refresh_token && !attempted.has(fresh.refresh_token)) { + attempted.add(fresh.refresh_token); + if (fresh.access_token && + (!fresh.expires_at || fresh.expires_at > Date.now() + 5 * 60_000)) { + return fresh; + } + attempts++; + try { + return await refreshAccessToken(fresh.refresh_token); + } catch (err) { + console.error("[oauth] disk-reread refresh also failed:", + err instanceof Error ? err.message : err); + if (!isInvalidGrantError(err) || attempts >= REFRESH_RECOVERY_MAX_ATTEMPTS) { + return null; + } + } + } + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await sleep(Math.min(REFRESH_RECOVERY_INTERVAL_MS, remaining)); } } -/** - * Get a valid access token, refreshing or re-authenticating as needed. - */ +function refreshWithRecovery(refreshToken: string): Promise { + if (!_refreshLock) { + // Capture the failed generation before any caller can replace the cache. + _refreshLock = (async () => { + try { + return await refreshAccessToken(refreshToken); + } catch (err) { + if (isInvalidGrantError(err)) { + const recovered = await tryRefreshViaDiskReread(refreshToken); + if (recovered) return recovered; + } + throw err; + } + })().then((tokens) => { + _cachedTokens = tokens; + return tokens; + }).finally(() => { + // Only the shared operation owns lock release, not its individual waiters. + _refreshLock = null; + }); + } + return _refreshLock; +} + +/** Get a valid access token, refreshing or re-authenticating as needed. */ async function getAccessToken(): Promise { if (!_cachedTokens) { _cachedTokens = await loadTokens(); @@ -540,24 +575,11 @@ async function getAccessToken(): Promise { } if (_cachedTokens.refresh_token) { - const previousRefresh = _cachedTokens.refresh_token; - if (!_refreshLock) { - _refreshLock = refreshAccessToken(previousRefresh); - } try { - _cachedTokens = await _refreshLock; + _cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token); return _cachedTokens.access_token; } catch (err) { - if (isInvalidGrantError(err)) { - const reread = await tryRefreshViaDiskReread(previousRefresh); - if (reread) { - _cachedTokens = reread; - return _cachedTokens.access_token; - } - } console.error("[oauth] refresh failed, falling back to full OAuth flow:", (err as Error).message || err); - } finally { - _refreshLock = null; } } else { console.error("[oauth] no refresh_token available, requiring full OAuth flow"); @@ -601,27 +623,14 @@ async function getAccessTokenForToolCall(): Promise { } if (_cachedTokens.refresh_token) { - const previousRefresh = _cachedTokens.refresh_token; - if (!_refreshLock) { - _refreshLock = refreshAccessToken(previousRefresh); - } try { - _cachedTokens = await _refreshLock; + _cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token); return _cachedTokens.access_token; } catch (err) { - if (isInvalidGrantError(err)) { - const reread = await tryRefreshViaDiskReread(previousRefresh); - if (reread) { - _cachedTokens = reread; - return _cachedTokens.access_token; - } - } console.error( "[oauth] refresh failed, starting web flow in background:", (err as Error).message || err, ); - } finally { - _refreshLock = null; } } } diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 571e716..40c2a7d 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -17,7 +17,8 @@ "github-webhook-mcp": "server/index.js" }, "devDependencies": { - "@anthropic-ai/mcpb": "^2.1.2" + "@anthropic-ai/mcpb": "^2.1.2", + "typescript": "^5.9.3" }, "engines": { "node": ">=18.0.0" @@ -1747,6 +1748,20 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index 6c88ecf..4f6ca73 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -23,7 +23,8 @@ "ws": "^8.18.0" }, "devDependencies": { - "@anthropic-ai/mcpb": "^2.1.2" + "@anthropic-ai/mcpb": "^2.1.2", + "typescript": "^5.9.3" }, "engines": { "node": ">=18.0.0" diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index 0e00f61..ddca5d2 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -454,27 +454,64 @@ function isInvalidGrantError(err) { return body.includes("invalid_grant"); } -/** - * RC1 fix: before giving up on a stale refresh_token, re-read the tokens file - * to see whether a concurrent process already rotated it. If the on-disk - * refresh_token differs from the one that just failed, adopt it and retry. - */ +// Only invalid_grant recovery pays this bounded disk-publication wait. +const REFRESH_RECOVERY_WAIT_MS = 1_000; +const REFRESH_RECOVERY_INTERVAL_MS = 50; +const REFRESH_RECOVERY_MAX_ATTEMPTS = 2; + async function tryRefreshViaDiskReread(previousRefreshToken) { - const fresh = await loadTokens(); - if (!fresh || !fresh.refresh_token) return null; - if (fresh.refresh_token === previousRefreshToken) return null; - try { - const tokens = await refreshAccessToken(fresh.refresh_token); - return tokens; - } catch (err) { - console.error( - "[oauth] disk-reread refresh also failed:", - err && err.message ? err.message : err, - ); - return null; + const deadline = Date.now() + REFRESH_RECOVERY_WAIT_MS; + const attempted = new Set([previousRefreshToken]); + let attempts = 0; + while (true) { + const fresh = await loadTokens(); + if (fresh && fresh.refresh_token && !attempted.has(fresh.refresh_token)) { + attempted.add(fresh.refresh_token); + if (fresh.access_token && + (!fresh.expires_at || fresh.expires_at > Date.now() + 5 * 60_000)) { + return fresh; + } + attempts++; + try { + return await refreshAccessToken(fresh.refresh_token); + } catch (err) { + console.error("[oauth] disk-reread refresh also failed:", + err && err.message ? err.message : err); + if (!isInvalidGrantError(err) || attempts >= REFRESH_RECOVERY_MAX_ATTEMPTS) { + return null; + } + } + } + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await sleep(Math.min(REFRESH_RECOVERY_INTERVAL_MS, remaining)); } } +function refreshWithRecovery(refreshToken) { + if (!_refreshLock) { + // Capture the failed generation before any caller can replace the cache. + _refreshLock = (async () => { + try { + return await refreshAccessToken(refreshToken); + } catch (err) { + if (isInvalidGrantError(err)) { + const recovered = await tryRefreshViaDiskReread(refreshToken); + if (recovered) return recovered; + } + throw err; + } + })().then((tokens) => { + _cachedTokens = tokens; + return tokens; + }).finally(() => { + // Only the shared operation owns lock release, not its individual waiters. + _refreshLock = null; + }); + } + return _refreshLock; +} + async function getAccessToken() { if (!_cachedTokens) { _cachedTokens = await loadTokens(); @@ -488,25 +525,11 @@ async function getAccessToken() { } if (_cachedTokens.refresh_token) { - if (!_refreshLock) { - _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); - } try { - _cachedTokens = await _refreshLock; + _cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token); return _cachedTokens.access_token; } catch (err) { - // RC1: maybe a sibling process already rotated. Re-read and retry once - // before falling through to a full web-flow restart. - if (isInvalidGrantError(err)) { - const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token); - if (reread) { - _cachedTokens = reread; - return _cachedTokens.access_token; - } - } console.error("[oauth] refresh failed, falling back to full OAuth flow:", err.message || err); - } finally { - _refreshLock = null; } } else { console.error("[oauth] no refresh_token available, requiring full OAuth flow"); @@ -548,26 +571,14 @@ async function getAccessTokenForToolCall() { } if (_cachedTokens.refresh_token) { - if (!_refreshLock) { - _refreshLock = refreshAccessToken(_cachedTokens.refresh_token); - } try { - _cachedTokens = await _refreshLock; + _cachedTokens = await refreshWithRecovery(_cachedTokens.refresh_token); return _cachedTokens.access_token; } catch (err) { - if (isInvalidGrantError(err)) { - const reread = await tryRefreshViaDiskReread(_cachedTokens.refresh_token); - if (reread) { - _cachedTokens = reread; - return _cachedTokens.access_token; - } - } console.error( "[oauth] refresh failed, starting web flow in background:", err.message || err, ); - } finally { - _refreshLock = null; } } } diff --git a/mcp-server/test/refresh-recovery.test.mjs b/mcp-server/test/refresh-recovery.test.mjs new file mode 100644 index 0000000..e6c5c33 --- /dev/null +++ b/mcp-server/test/refresh-recovery.test.mjs @@ -0,0 +1,192 @@ +/** Executes the shipped authentication functions in isolation (docs F7.11). + * File reads, refresh transport, clock and OAuth entry are synthetic dependencies; + * the bridge's top-level stdio startup is deliberately excluded. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; +import vm from 'node:vm'; + +const sources = [ + ['JS', new URL('../server/index.js', import.meta.url)], + ['TS', new URL('../../local-mcp/src/index.ts', import.meta.url)], +]; +const invalidGrant = () => Object.assign(new Error('synthetic invalid_grant'), { + status: 400, bodyText: '{"error":"invalid_grant"}', +}); +const expired = (refresh_token = 'old') => ({ + flow: 'web', access_token: 'expired', refresh_token, expires_at: 1, +}); +const usable = (refresh_token = 'rotated') => ({ + flow: 'web', access_token: 'recovered', refresh_token, expires_at: 10_000_000, +}); + +async function harness(url, { disk, refresh } = {}) { + let now = 10_000; + let reads = 0; + let fallbacks = 0; + const waits = []; + const calls = []; + const source = await readFile(url, 'utf8'); + const storage = source.slice(source.indexOf('async function loadTokens'), source.indexOf('async function saveTokens')); + const start = source.indexOf('function isInvalidGrantError'); + const end = source.indexOf('// \u2500\u2500 Remote MCP Client', start); + assert.ok(start >= 0 && end > start); + let code = storage + source.slice(start, end); + if (url.pathname.endsWith('.ts')) { + code = ts.transpileModule(code, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, + }).outputText; + } + const context = vm.createContext({ + console: { error() {} }, + Date: { now: () => now }, + setImmediate, + TOKEN_FILE: 'synthetic-only', TOKENS_FLOW_MARKER: 'web', + readFile: async () => { + reads++; + const value = disk ? disk(now, reads) : expired(); + if (value === null) throw new Error('synthetic missing file'); + return typeof value === 'string' ? value : JSON.stringify(value); + }, + sleep: async (ms) => { waits.push(ms); now += ms; }, + refreshAccessToken: async (token) => { + calls.push(token); + return refresh ? refresh(token, calls.length) : usable(); + }, + performOAuthFlow: async () => { + fallbacks++; + vm.runInContext('_pendingWebAuth = { authorize_url: "https://example.invalid/synthetic" };', context); + return usable('fallback'); + }, + }); + vm.runInContext(`let _cachedTokens = ${JSON.stringify(expired())}; + let _refreshLock = null, _webAuthLock = null; + let _pendingWebAuth = null, _pendingWebAuthError = null; + ${code}`, context); + return { + get: (name) => vm.runInContext(`${name}()`, context), + setCache: (tokens) => vm.runInContext(`_cachedTokens = ${JSON.stringify(tokens)}`, context), + lock: () => vm.runInContext('_refreshLock', context), + calls, waits, + reads: () => reads, fallbacks: () => fallbacks, now: () => now, + }; +} + +for (const [language, url] of sources) { + for (const entry of ['getAccessToken', 'getAccessTokenForToolCall']) { + test(`${language} ${entry}: delayed disk generation and concurrent callers share recovery`, async () => { + const h = await harness(url, { + disk: (now) => expired(now >= 10_150 ? 'new' : 'old'), + refresh: async (token) => { if (token === 'old') throw invalidGrant(); return usable(); }, + }); + assert.deepEqual(await Promise.all([h.get(entry), h.get(entry)]), ['recovered', 'recovered']); + assert.deepEqual(h.calls, ['old', 'new']); + assert.equal(h.fallbacks(), 0); + assert.equal(h.now(), 10_150); + assert.equal(h.lock(), null); + }); + for (const [name, disk] of [ + ['unchanged file', () => expired()], + ['missing file', () => null], + ['malformed file', () => '{'], + ['legacy file', () => ({ ...expired(), flow: 'legacy' })], + ]) { + test(`${language} ${entry}: ${name} expires bounded wait and preserves fallback`, async () => { + const h = await harness(url, { disk, refresh: async () => { throw invalidGrant(); } }); + if (entry === 'getAccessToken') assert.equal(await h.get(entry), 'recovered'); + else await assert.rejects(h.get(entry), { name: 'AuthRequiredError' }); + assert.equal(h.fallbacks(), 1); + assert.equal(h.now(), 11_000); + assert.equal(h.reads(), 21); + assert.deepEqual(h.calls, ['old']); + assert.equal(h.lock(), null); + }); + } + test(`${language} ${entry}: other refresh errors do not wait`, async () => { + const h = await harness(url, { refresh: async () => { throw new Error('synthetic transport failure'); } }); + if (entry === 'getAccessToken') await h.get(entry); + else await assert.rejects(h.get(entry), { name: 'AuthRequiredError' }); + assert.equal(h.reads(), 0); + assert.deepEqual(h.waits, []); + assert.equal(h.fallbacks(), 1); + assert.equal(h.lock(), null); + }); + test(`${language} ${entry}: usable newer disk token avoids redundant refresh`, async () => { + const h = await harness(url, { disk: () => usable('new'), refresh: async () => { throw invalidGrant(); } }); + assert.equal(await h.get(entry), 'recovered'); + assert.deepEqual(h.calls, ['old']); + assert.deepEqual(h.waits, []); + assert.equal(h.fallbacks(), 0); + }); + test(`${language} ${entry}: changing generations cannot cause unlimited refresh`, async () => { + const h = await harness(url, { disk: (_, reads) => expired(`generation-${reads}`), refresh: async () => { throw invalidGrant(); } }); + if (entry === 'getAccessToken') await h.get(entry); + else await assert.rejects(h.get(entry), { name: 'AuthRequiredError' }); + assert.deepEqual(h.calls, ['old', 'generation-1', 'generation-2']); + assert.equal(h.fallbacks(), 1); + assert.equal(h.lock(), null); + }); + test(`${language} ${entry}: second rotated generation can recover after invalid_grant`, async () => { + const h = await harness(url, { + disk: (_, reads) => expired(reads < 3 ? 'new' : 'newer'), + refresh: async (token) => { if (token !== 'newer') throw invalidGrant(); return usable(); }, + }); + assert.equal(await h.get(entry), 'recovered'); + assert.deepEqual(h.calls, ['old', 'new', 'newer']); + assert.equal(h.fallbacks(), 0); + assert.equal(h.lock(), null); + }); + test(`${language} ${entry}: renewed token with no expiry can be adopted`, async () => { + const h = await harness(url, { + disk: () => ({ ...usable('new'), expires_at: undefined }), + refresh: async () => { throw invalidGrant(); }, + }); + assert.equal(await h.get(entry), 'recovered'); + assert.deepEqual(h.calls, ['old']); + assert.equal(h.fallbacks(), 0); + }); + test(`${language} ${entry}: recovery transport failure stops further waits`, async () => { + const h = await harness(url, { + disk: () => expired('new'), + refresh: async (token) => { throw token === 'old' ? invalidGrant() : new Error('synthetic transport failure'); }, + }); + if (entry === 'getAccessToken') await h.get(entry); + else await assert.rejects(h.get(entry), { name: 'AuthRequiredError' }); + assert.deepEqual(h.calls, ['old', 'new']); + assert.deepEqual(h.waits, []); + assert.equal(h.fallbacks(), 1); + }); + test(`${language} ${entry}: successful refresh and valid cache have no wait`, async () => { + const h = await harness(url); + assert.equal(await h.get(entry), 'recovered'); + assert.equal(await h.get(entry), 'recovered'); + assert.deepEqual(h.calls, ['old']); + assert.deepEqual(h.waits, []); + assert.equal(h.reads(), 0); + assert.equal(h.fallbacks(), 0); + }); + test(`${language} ${entry}: failed operation releases lock for later recovery`, async () => { + let failing = true; + const h = await harness(url, { refresh: async () => { if (failing) throw invalidGrant(); return usable(); } }); + if (entry === 'getAccessToken') await h.get(entry); + else await assert.rejects(h.get(entry), { name: 'AuthRequiredError' }); + assert.equal(h.lock(), null); + failing = false; + h.setCache(expired('later')); + assert.equal(await h.get(entry), 'recovered'); + assert.deepEqual(h.calls, ['old', 'later']); + assert.equal(h.lock(), null); + }); + } + test(`${language}: both access-token entrances share one recovery operation`, async () => { + const h = await harness(url, { + disk: (now) => expired(now >= 10_100 ? 'new' : 'old'), + refresh: async (token) => { if (token === 'old') throw invalidGrant(); return usable(); }, + }); + assert.deepEqual(await Promise.all([h.get('getAccessToken'), h.get('getAccessTokenForToolCall')]), ['recovered', 'recovered']); + assert.deepEqual(h.calls, ['old', 'new']); + assert.equal(h.fallbacks(), 0); + }); +}