Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@
"open-sse/mcp-server/schemas/tools.ts": 1497,
"open-sse/mcp-server/server.ts": 1555,
"open-sse/mcp-server/tools/advancedTools.ts": 1118,
"open-sse/services/accountFallback.ts": 1773,
"_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.",
"open-sse/services/accountFallback.ts": 1777,
"open-sse/services/batchProcessor.ts": 828,
"open-sse/services/browserBackedChat.ts": 850,
"open-sse/services/claudeCodeCompatible.ts": 1202,
Expand Down Expand Up @@ -206,7 +207,7 @@
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1121,
"src/app/api/oauth/[provider]/[action]/route.ts": 924,
"src/app/api/providers/[id]/models/route.ts": 2593,
"src/app/api/providers/[id]/test/route.ts": 924,
"src/app/api/providers/[id]/test/route.ts": 940,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1615,
"src/lib/cloudflaredTunnel.ts": 934,
Expand All @@ -227,7 +228,8 @@
"src/lib/usage/callLogs.ts": 975,
"src/lib/usage/providerLimits.ts": 955,
"src/lib/usage/usageHistory.ts": 983,
"src/shared/components/OAuthModal.tsx": 960,
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
"src/shared/components/OAuthModal.tsx": 969,
"src/shared/components/RequestLoggerV2.tsx": 1316,
"src/shared/components/analytics/charts.tsx": 1558,
"src/shared/constants/cliTools.ts": 875,
Expand Down Expand Up @@ -270,7 +272,8 @@
"tests/unit/models-catalog-route.test.ts": 1507,
"_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').",
"tests/unit/oauth-providers-config.test.ts": 870,
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
"tests/unit/oauth-providers-config.test.ts": 873,
"tests/unit/perplexity-web.test.ts": 959,
"tests/unit/provider-models-route.test.ts": 1618,
"tests/unit/provider-validation-specialty.test.ts": 2801,
Expand Down
4 changes: 3 additions & 1 deletion src/lib/oauth/constants/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ export const ANTIGRAVITY_CONFIG = {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
// No "openid" scope — the working 9router flow requests only the Cloud Code /
// userinfo scopes below. "openid" (with PKCE) routed Google into the hanging
// `firstparty/nativeapp` consent. Match 9router exactly (antigravity login fix).
scopes: [
"openid",
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
Expand Down
84 changes: 53 additions & 31 deletions src/lib/oauth/providers/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ import {
} from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts";

async function fetchFirstOk(endpoints: string[], init: RequestInit) {
// Bound every Antigravity post-exchange call. Without this an unreachable/slow
// upstream made the `/exchange` request (and therefore the whole OAuth login)
// hang forever — the dashboard "just spins". Mirrors the AbortSignal.timeout
// pattern already used by antigravityProjectBootstrap.ts.
const POSTEXCHANGE_TIMEOUT_MS = 8_000;

async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
let lastError: unknown = null;
// One shared deadline for the WHOLE fallback list — a stalled set of endpoints
// must bound to a single timeout, not timeoutMs × endpoints (that re-introduced
// a ~40s login wait). A reachable endpoint still returns immediately.
const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : init.signal;
for (const endpoint of endpoints) {
try {
const response = await fetch(endpoint, init);
const response = await fetch(endpoint, { ...init, signal });
if (response.ok) return response;
lastError = new Error(`${response.status} ${await response.text()}`);
} catch (error) {
Expand All @@ -22,7 +32,13 @@ async function fetchFirstOk(endpoints: string[], init: RequestInit) {

export const antigravity = {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code_pkce",
// NO PKCE. The embedded Antigravity client is a Google "Desktop/native" OAuth client;
// sending a PKCE code_challenge (combined with the openid scope) pushed Google into the
// `signin/oauth/firstparty/nativeapp` consent flow that hangs and never redirects back
// (operator report 2026-06-27). The working 9router flow uses a plain authorization_code
// grant with client_secret and no code_challenge — match it exactly. The token exchange
// already sends client_secret (ANTIGRAVITY_OAUTH_CLIENT_SECRET) and omits code_verifier.
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
client_id: config.clientId,
Expand All @@ -39,7 +55,13 @@ export const antigravity = {
}
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
// NOTE: no PKCE. Antigravity is a plain authorization_code grant now (see flowType
// above). The shared generateAuthData() still mints a codeVerifier for every flow, but
// we MUST NOT forward it here — the authorize URL carries no code_challenge, so sending
// a code_verifier makes Google reject the exchange with invalid_grant ("code_verifier
// provided but code_challenge was not"), surfacing as a 500 on /exchange. Ignore it and
// authenticate with client_secret only, exactly like the working 9router flow.
exchangeToken: async (config, code, redirectUri) => {
const bodyParams: Record<string, string> = {
grant_type: "authorization_code",
client_id: config.clientId,
Expand All @@ -51,10 +73,6 @@ export const antigravity = {
bodyParams.client_secret = config.clientSecret;
}

if (codeVerifier) {
bodyParams.code_verifier = codeVerifier;
}

const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
Expand All @@ -76,48 +94,52 @@ export const antigravity = {
const headers = getAntigravityHeaders("loadCodeAssist", tokens.access_token);
const metadata = getAntigravityLoadCodeAssistMetadata();

// Best-effort + bounded: a slow userinfo endpoint must never hang the login.
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS),
}).catch(() => null);
const userInfo = userInfoRes?.ok ? await userInfoRes.json() : {};
Comment on lines 98 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the userInfo request succeeds but the response body is invalid JSON or empty, calling userInfoRes.json() will throw an unhandled exception and crash the entire postExchange (and thus the OAuth login) flow. Wrapping both the fetch and the JSON parsing in a single try-catch block ensures that any network, timeout, or parsing errors are safely caught and handled gracefully.

    let userInfo = {};
    try {
      const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
        signal: AbortSignal.timeout(POSTEXCHANGE_TIMEOUT_MS),
      });
      if (userInfoRes.ok) {
        userInfo = await userInfoRes.json();
      }
    } catch (e) {
      console.log("Failed to fetch user info:", e);
    }


let projectId = "";
let tierId = "legacy-tier";
try {
const loadRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, {
method: "POST",
headers,
body: JSON.stringify({ metadata }),
});
const loadRes = await fetchFirstOk(
ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints,
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
const data = await loadRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
tierId = extractCodeAssistOnboardTierId(data);
} catch (e) {
console.log("Failed to load code assist:", e);
}

// Fire-and-forget onboarding — it must NOT block the OAuth login response.
// The previous inline `await` loop (up to 10×5s, each fetch un-timed) made the
// `/exchange` request hang forever when an upstream was slow/unreachable, so the
// dashboard "just spun". Onboarding is also performed lazily at request time by
// antigravityProjectBootstrap.ts, so backgrounding it here is safe. Matches the
// 9router web flow. (#5180-followup / antigravity login hang)
if (projectId) {
try {
const onboardInBackground = async () => {
for (let i = 0; i < 10; i++) {
const onboardRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.onboardUserEndpoints, {
method: "POST",
headers,
body: JSON.stringify({ tier_id: tierId, metadata }),
});
const result = await onboardRes.json();
if (result.done === true) {
if (result.response?.cloudaicompanionProject) {
const respProject = result.response.cloudaicompanionProject;
projectId =
typeof respProject === "string" ? respProject.trim() : respProject.id || projectId;
}
try {
const onboardRes = await fetchFirstOk(
ANTIGRAVITY_CONFIG.onboardUserEndpoints,
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
POSTEXCHANGE_TIMEOUT_MS
);
const result = await onboardRes.json();
if (result.done === true) break;
} catch {
break;
}
Comment on lines +136 to 138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Breaking the loop on any error (catch { break; }) means that if the first onboarding attempt fails (e.g., due to a temporary network issue or a timeout), the background process will immediately stop retrying. Since the goal of this background loop is to retry up to 10 times, we should log the error and let the loop continue to the next iteration after the 5-second delay.

Suggested change
} catch {
break;
}
} catch (e) {
console.log("Onboarding attempt failed:", e);
}

await new Promise((resolve) => setTimeout(resolve, 5000));
}
} catch (e) {
console.log("Failed to onboard user:", e);
}
};
void onboardInBackground().catch(() => {});
}

return { userInfo, projectId, tierId };
Expand Down
12 changes: 10 additions & 2 deletions src/shared/components/OAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -849,8 +849,16 @@ export default function OAuthModal({
</strong>
</div>
)}
{/* Generic remote info for other providers */}
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && (
{/* Actionable remote paste instruction — shown for ALL remote providers,
including Google OAuth (antigravity/agy/gemini-cli). The Google
loopback creds redirect to 127.0.0.1:<port>/callback, which on a
remotely-accessed dashboard lands on the operator's own machine and
shows a "can't reach this page" error. That is expected: the URL bar
still carries ?code=…, and pasting it below completes the login. Before
this, Google providers only saw the discouraging loopback warning and
never the "copy the URL and paste it" step, so remote login appeared to
hang. */}
{!isTrueLocalhost && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Regression guard for the Antigravity OAuth login hang on Google's consent page.
//
// The embedded Antigravity client is a Google "Desktop/native" OAuth client.
// Sending a PKCE code_challenge AND the `openid` scope pushed Google into the
// `signin/oauth/firstparty/nativeapp` consent flow, which hung and never redirected
// back (operator report 2026-06-27). The working 9router flow uses a plain
// authorization_code grant (client_secret, no code_challenge) and does NOT request
// `openid`. This test pins our antigravity (and the `agy` alias) to that shape.
//
// Flip-proof: set flowType back to "authorization_code_pkce" → generateAuthData emits
// code_challenge → first assertion fails. Re-add "openid" → scope assertion fails.

import test from "node:test";
import assert from "node:assert/strict";
import { generateAuthData } from "../../src/lib/oauth/providers.ts";
import PROVIDERS from "../../src/lib/oauth/providers/index.ts";

const REDIRECT = "http://127.0.0.1:20128/callback";

for (const providerId of ["antigravity", "agy"]) {
test(`${providerId}: no PKCE + no openid in the auth URL (matches working 9router flow)`, () => {
assert.equal(
PROVIDERS[providerId].flowType,
"authorization_code",
`${providerId} must use a plain authorization_code grant (no PKCE) for the Google native client`
);

const authData = generateAuthData(providerId, REDIRECT);
assert.ok(authData.authUrl, `${providerId} must produce an auth URL`);

const url = new URL(authData.authUrl);
assert.equal(url.origin, "https://accounts.google.com");

// No PKCE challenge — its presence triggers the hanging nativeapp consent.
assert.equal(
url.searchParams.get("code_challenge"),
null,
`${providerId} auth URL must NOT carry a PKCE code_challenge`
);
assert.equal(url.searchParams.get("code_challenge_method"), null);

// No openid scope — only the Cloud Code / userinfo scopes 9router requests.
const scopes = (url.searchParams.get("scope") || "").split(" ");
assert.ok(!scopes.includes("openid"), `${providerId} must not request the openid scope`);
assert.ok(
scopes.includes("https://www.googleapis.com/auth/cloud-platform"),
`${providerId} must still request the cloud-platform scope`
);
});
}

test("antigravity.exchangeToken never forwards code_verifier (no PKCE → no invalid_grant 500)", async () => {
const origFetch = globalThis.fetch;
let sentBody = "";
globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => {
sentBody = String(init.body ?? "");
return new Response(
JSON.stringify({ access_token: "t", refresh_token: "r", expires_in: 3600 }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}) as typeof fetch;
try {
// Pass a codeVerifier (as the modal does — generateAuthData always mints one).
// It MUST be ignored: the authorize URL had no code_challenge, so forwarding a
// code_verifier makes Google reject the exchange (invalid_grant → 500).
await PROVIDERS.antigravity.exchangeToken(
{
clientId: "cid",
clientSecret: "sec",
tokenUrl: "https://oauth2.googleapis.com/token",
},
"the-code",
"http://127.0.0.1:20128/callback",
"should-be-ignored-verifier"
);
} finally {
globalThis.fetch = origFetch;
}
const params = new URLSearchParams(sentBody);
assert.equal(params.get("code_verifier"), null, "must NOT forward code_verifier (no PKCE)");
assert.equal(params.get("client_secret"), "sec", "must authenticate via client_secret");
assert.equal(params.get("grant_type"), "authorization_code");
});
Loading
Loading