diff --git a/changelog.d/fixes/8860-tokenrefresh-projectid.md b/changelog.d/fixes/8860-tokenrefresh-projectid.md new file mode 100644 index 0000000000..3a091523cb --- /dev/null +++ b/changelog.d/fixes/8860-tokenrefresh-projectid.md @@ -0,0 +1 @@ +- **Antigravity**: the shared token-refresh service now recovers a missing `projectId` too — #8842 fixed the executor path, but the Dashboard and the health check refresh through `open-sse/services/tokenRefresh.ts`, which kept returning credentials with an empty project and left those accounts on 422 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 5acc9b8e5d..64f5b58f42 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -197,7 +197,7 @@ "tests/unit/search-handler-extended.test.ts": 1071, "tests/unit/sse-auth.test.ts": 1600, "tests/unit/stream-utils.test.ts": 2445, - "tests/unit/token-refresh-service.test.ts": 1311, + "tests/unit/token-refresh-service.test.ts": 1378, "tests/unit/translator-openai-responses-req.test.ts": 1194, "tests/unit/translator-openai-to-gemini.test.ts": 1616, "tests/unit/translator-openai-to-kiro.test.ts": 1250, @@ -409,5 +409,6 @@ "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", "_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) — owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* ≡ codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.", "_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).", - "_rebaseline_2026_07_28_8842_antigravity_test": "PR #8842 test growth: tests/unit/executor-antigravity.test.ts new testFrozen 1098 (4 new test cases for projectId discovery during refresh: discovers when empty, skips when set, handles failure, skips when access_token not a string). All above cap 1000 on day one." + "_rebaseline_2026_07_28_8842_antigravity_test": "PR #8842 test growth: tests/unit/executor-antigravity.test.ts new testFrozen 1098 (4 new test cases for projectId discovery during refresh: discovers when empty, skips when set, handles failure, skips when access_token not a string). All above cap 1000 on day one.", + "_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file." } diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 13c9e23677..b4a6e2afe7 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -45,6 +45,8 @@ import { refreshKimiCodingToken } from "./tokenRefresh/providers/kimiCoding.ts"; import { refreshGitLabDuoToken } from "./tokenRefresh/providers/gitlabDuo.ts"; import { refreshClaudeOAuthToken } from "./tokenRefresh/providers/claudeOAuth.ts"; import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; +import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; +import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts"; import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts"; @@ -275,8 +277,8 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "gemini": case "antigravity": - case "agy": - return await refreshGoogleToken( + case "agy": { + const result = await refreshGoogleToken( credentials.refreshToken, PROVIDERS[provider].clientId, PROVIDERS[provider].clientSecret, @@ -284,6 +286,45 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: proxyConfig ); + // Google One AI accounts get no projectId at OAuth exchange time. + // Recover it via loadCodeAssist so downstream routing works. + if ( + result?.accessToken && + (provider === "antigravity" || provider === "agy") && + !(credentials.projectId || credentials.providerSpecificData?.projectId) + ) { + try { + const discovered = await ensureAntigravityProjectAssigned( + result.accessToken, + fetch + ); + if (discovered) { + result.projectId = discovered; + result.providerSpecificData = { + ...(credentials.providerSpecificData || {}), + ...(result.providerSpecificData || {}), + projectId: discovered, + }; + if (credentials.connectionId) { + await persistDiscoveredAntigravityProjectId( + credentials.connectionId, + discovered, + credentials.providerSpecificData + ); + } + log?.info?.("TOKEN", "Antigravity projectId discovered during token refresh", { + projectId: discovered, + }); + } + } catch (discoveryError) { + const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError); + log?.warn?.("TOKEN", `Antigravity projectId discovery failed: ${msg}`); + } + } + + return result; + } + case "claude": return await refreshClaudeOAuthToken(credentials.refreshToken, log, proxyConfig); diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts index eb75adb0d3..fdc7ef16c0 100644 --- a/tests/unit/token-refresh-service.test.ts +++ b/tests/unit/token-refresh-service.test.ts @@ -795,6 +795,73 @@ test("supportsTokenRefresh, isUnrecoverableRefreshError and formatProviderCreden assert.equal(formatProviderCredentials("missing-provider", {}, log), null); }); +test("getAccessToken discovers projectId for antigravity when stored value is empty", async () => { + const log = createLog(); + const { clearAntigravityProjectCache } = await import("../../open-sse/services/antigravityProjectBootstrap.ts"); + clearAntigravityProjectCache(); + + let fetchCalls: string[] = []; + await withMockedFetch(async (url, init) => { + const urlStr = String(url); + fetchCalls.push(urlStr); + if (urlStr.includes("oauth2.googleapis.com/token")) { + return jsonResponse({ + access_token: "new-token-1", + refresh_token: "new-refresh", + expires_in: 3600, + }); + } + if (urlStr.includes("loadCodeAssist")) { + return jsonResponse({ cloudaicompanionProject: "discovered-project" }); + } + return new Response("not found", { status: 404 }); + }, async () => { + const result = await getAccessToken("antigravity", { + refreshToken: "refresh", + projectId: "", + connectionId: "conn-1", + providerSpecificData: {}, + }); + assert.equal(result.projectId, "discovered-project"); + assert.ok(fetchCalls.some((u) => u.includes("loadCodeAssist")), "should call loadCodeAssist"); + }); + clearAntigravityProjectCache(); +}); + + +test("getAccessToken handles projectId discovery failure gracefully for antigravity", async () => { + const log = createLog(); + const { clearAntigravityProjectCache } = await import("../../open-sse/services/antigravityProjectBootstrap.ts"); + clearAntigravityProjectCache(); + tokenRefresh._clearTokenRotationMap(); + + await withMockedFetch(async (url) => { + const urlStr = String(url); + if (urlStr.includes("oauth2.googleapis.com/token")) { + return jsonResponse({ + access_token: "new-token-3", + refresh_token: "new-refresh", + expires_in: 3600, + }); + } + if (urlStr.includes("loadCodeAssist")) { + return new Response("server error", { status: 500 }); + } + return new Response("not found", { status: 404 }); + }, async () => { + const result = await getAccessToken("antigravity", { + refreshToken: "refresh", + projectId: "", + connectionId: "conn-1", + providerSpecificData: {}, + }); + assert.equal(result.accessToken, "new-token-3"); + assert.ok(result, "should return a result without throwing"); + }); + clearAntigravityProjectCache(); +}); + + test("getAccessToken deduplicates concurrent refreshes for the same provider and token", async () => { const log = createLog(); let fetchCount = 0;