From eda02941367217a31f2ad94b7a809e57f5f7b94f Mon Sep 17 00:00:00 2001 From: koreankop Date: Fri, 21 Aug 2026 09:55:15 +0900 Subject: [PATCH] fix(keychain): keep using a valid connector token when its refresh fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refresh runs as soon as a token enters the refresh margin, and its result was returned unconditionally — so one failed refresh handed the turn nothing, even though the stored token typically had minutes of life left. A machine waking with DNS not yet up therefore knocked healthy connectors out for the whole turn: refreshes failed with 'fetch failed', tools got no token, and work whose grant was perfectly fine came back as 401s and 'needs reconnect'. Fall back to the stored token when a refresh fails and the token has not yet reached its expiry skew. The failure is still recorded on the record, so a genuine revocation still surfaces as needs-reconnect the moment the token actually expires — the outage window just stops taking working credentials down with it. --- src/credentials/keychain.ts | 6 ++-- test/keychain.test.ts | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/credentials/keychain.ts b/src/credentials/keychain.ts index d7b088fa6..b30f1129a 100644 --- a/src/credentials/keychain.ts +++ b/src/credentials/keychain.ts @@ -692,9 +692,9 @@ export function createKeychain(deps: { inflightRefreshes.set(rec.id, pending); void pending.finally(() => inflightRefreshes.delete(rec.id)); } - return pending; - } - if (oauthExpired(rec, t) && !refreshable) return null; + const refreshed = await pending; + if (refreshed || oauthExpired(rec, t)) return refreshed; + } else if (oauthExpired(rec, t) && !refreshable) return null; return tryDecrypt(rec, (r) => decryptSecret(r.secretEnc, deps.key)); } diff --git a/test/keychain.test.ts b/test/keychain.test.ts index 4994676b7..ae8ef2e48 100644 --- a/test/keychain.test.ts +++ b/test/keychain.test.ts @@ -1458,3 +1458,59 @@ test("turn e2e: manifest in the prompt, no secret without a grant, standing gran assert.equal((await built.app.turn(dm)).status, "ok"); assert.ok(execScriptsMention("ghp_e2e", mark), "owner's personal scope gets their own keychain creds"); }); + +describe("a failed connector refresh does not discard a token that still works", () => { + const GMAIL = "gmail.googleapis.com"; + type Rec = import("../src/credentials/keychain.ts").KeychainCredential; + function kcFailingRefresh(): Keychain { + return createKeychain({ + creds: createMemoryMap(), + grants: createMemoryMap(), + asks: createMemoryMap(), + key: KEY, + refreshConnector: async () => { + throw new Error("fetch failed"); + }, + }); + } + + it("falls back to the stored token while it is still valid", async () => { + const k = kcFailingRefresh(); + await k.setConnectorToken(GMAIL, "alex@x", { + accessToken: "ya29.still-good", + refreshToken: "rt", + expiresAt: Date.now() + 5 * 60_000, + }); + assert.equal(await k.connectorAccessToken(GMAIL, "alex@x"), "ya29.still-good"); + const status = await k.connectorTokenStatus(GMAIL, "alex@x"); + assert.equal(typeof status.refreshFailedAt, "number", "the failure is still recorded"); + assert.equal(status.needsReconnect, undefined, "a usable token is not a reconnect prompt"); + }); + + it("returns nothing once the stored token is past its skew", async () => { + const k = kcFailingRefresh(); + await k.setConnectorToken(GMAIL, "alex@x", { + accessToken: "ya29.dead", + refreshToken: "rt", + expiresAt: Date.now() + 10_000, + }); + assert.equal(await k.connectorAccessToken(GMAIL, "alex@x"), null); + assert.equal((await k.connectorTokenStatus(GMAIL, "alex@x")).needsReconnect, true); + }); + + it("still prefers a successful refresh over the stored token", async () => { + const k = createKeychain({ + creds: createMemoryMap(), + grants: createMemoryMap(), + asks: createMemoryMap(), + key: KEY, + refreshConnector: async () => ({ accessToken: "ya29.fresh", expiresAt: Date.now() + 3_600_000 }), + }); + await k.setConnectorToken(GMAIL, "alex@x", { + accessToken: "ya29.aging", + refreshToken: "rt", + expiresAt: Date.now() + 5 * 60_000, + }); + assert.equal(await k.connectorAccessToken(GMAIL, "alex@x"), "ya29.fresh"); + }); +});