Skip to content

Commit 5025723

Browse files
author
tctinh
committed
fix: resolve OAuth callback hanging in WSL/SSH/remote environments
- Fix IPv4/IPv6 mismatch by binding server to all interfaces - Add WSL/SSH/remote environment detection to skip unreachable local server - Add 30s timeout fallback with manual URL input prompt - Add --no-browser flag support for headless environments - Add fetch timeout (10s) to fetchProjectID() to prevent indefinite hangs - Improve openBrowser() with WSL wslview support
1 parent fb85f36 commit 5025723

3 files changed

Lines changed: 167 additions & 41 deletions

File tree

src/antigravity/oauth.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,24 @@ export async function authorizeAntigravity(projectId = ""): Promise<AntigravityA
112112
};
113113
}
114114

115+
const FETCH_TIMEOUT_MS = 10000;
116+
117+
async function fetchWithTimeout(
118+
url: string,
119+
options: RequestInit,
120+
timeoutMs = FETCH_TIMEOUT_MS,
121+
): Promise<Response> {
122+
const controller = new AbortController();
123+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
124+
try {
125+
return await fetch(url, { ...options, signal: controller.signal });
126+
} finally {
127+
clearTimeout(timeout);
128+
}
129+
}
130+
115131
async function fetchProjectID(accessToken: string): Promise<string> {
116132
const errors: string[] = [];
117-
// Use CLIProxy-aligned headers for project discovery to match "real" Antigravity clients.
118133
const loadHeaders: Record<string, string> = {
119134
Authorization: `Bearer ${accessToken}`,
120135
"Content-Type": "application/json",
@@ -130,7 +145,7 @@ async function fetchProjectID(accessToken: string): Promise<string> {
130145
for (const baseEndpoint of loadEndpoints) {
131146
try {
132147
const url = `${baseEndpoint}/v1internal:loadCodeAssist`;
133-
const response = await fetch(url, {
148+
const response = await fetchWithTimeout(url, {
134149
method: "POST",
135150
headers: loadHeaders,
136151
body: JSON.stringify({

src/plugin.ts

Lines changed: 149 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,68 @@ function clearWarmupAttempt(sessionId: string): void {
9191
warmupAttemptedSessionIds.delete(sessionId);
9292
}
9393

94-
async function openBrowser(url: string): Promise<void> {
94+
function isWSL(): boolean {
95+
if (process.platform !== "linux") return false;
96+
try {
97+
const { readFileSync } = require("node:fs");
98+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
99+
return release.includes("microsoft") || release.includes("wsl");
100+
} catch {
101+
return false;
102+
}
103+
}
104+
105+
function isWSL2(): boolean {
106+
if (!isWSL()) return false;
107+
try {
108+
const { readFileSync } = require("node:fs");
109+
const version = readFileSync("/proc/version", "utf8").toLowerCase();
110+
return version.includes("wsl2") || version.includes("microsoft-standard");
111+
} catch {
112+
return false;
113+
}
114+
}
115+
116+
function isRemoteEnvironment(): boolean {
117+
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
118+
return true;
119+
}
120+
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
121+
return true;
122+
}
123+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) {
124+
return true;
125+
}
126+
return false;
127+
}
128+
129+
function shouldSkipLocalServer(): boolean {
130+
return isWSL2() || isRemoteEnvironment();
131+
}
132+
133+
async function openBrowser(url: string): Promise<boolean> {
95134
try {
96135
if (process.platform === "darwin") {
97136
exec(`open "${url}"`);
98-
return;
137+
return true;
99138
}
100139
if (process.platform === "win32") {
101-
exec(`start "${url}"`);
102-
return;
140+
exec(`start "" "${url}"`);
141+
return true;
142+
}
143+
if (isWSL()) {
144+
try {
145+
exec(`wslview "${url}"`);
146+
return true;
147+
} catch {}
148+
}
149+
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
150+
return false;
103151
}
104152
exec(`xdg-open "${url}"`);
153+
return true;
105154
} catch {
106-
// ignore
155+
return false;
107156
}
108157
}
109158

@@ -168,6 +217,24 @@ function parseOAuthCallbackInput(
168217
}
169218
}
170219

220+
async function promptManualOAuthInput(
221+
fallbackState: string,
222+
): Promise<AntigravityTokenExchangeResult> {
223+
console.log("1. Open the URL above in your browser and complete Google sign-in.");
224+
console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
225+
console.log("3. Paste it back here.\n");
226+
227+
const callbackInput = await promptOAuthCallbackValue(
228+
"Paste the redirect URL (or just the code) here: ",
229+
);
230+
const params = parseOAuthCallbackInput(callbackInput, fallbackState);
231+
if ("error" in params) {
232+
return { type: "failed", error: params.error };
233+
}
234+
235+
return exchangeAntigravity(params.code, params.state);
236+
}
237+
171238
function clampInt(value: number, min: number, max: number): number {
172239
if (!Number.isFinite(value)) {
173240
return min;
@@ -1481,6 +1548,8 @@ export const createAntigravityPlugin = (providerId: string) => async (
14811548
// CLI flow (`opencode auth login`) passes an inputs object.
14821549
if (inputs) {
14831550
const accounts: Array<Extract<AntigravityTokenExchangeResult, { type: "success" }>> = [];
1551+
const noBrowser = inputs.noBrowser === "true" || inputs["no-browser"] === "true";
1552+
const useManualMode = noBrowser || shouldSkipLocalServer();
14841553

14851554
// Check for existing accounts and prompt user for login mode
14861555
let startFresh = true;
@@ -1507,6 +1576,20 @@ export const createAntigravityPlugin = (providerId: string) => async (
15071576
const projectId = await promptProjectId();
15081577

15091578
const result = await (async (): Promise<AntigravityTokenExchangeResult> => {
1579+
const authorization = await authorizeAntigravity(projectId);
1580+
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
1581+
1582+
console.log("\nOAuth URL:\n" + authorization.url + "\n");
1583+
1584+
if (useManualMode) {
1585+
const browserOpened = await openBrowser(authorization.url);
1586+
if (!browserOpened) {
1587+
console.log("Could not open browser automatically.");
1588+
console.log("Please open the URL above manually in your local browser.\n");
1589+
}
1590+
return promptManualOAuthInput(fallbackState);
1591+
}
1592+
15101593
let listener: OAuthListener | null = null;
15111594
if (!isHeadless) {
15121595
try {
@@ -1516,53 +1599,62 @@ export const createAntigravityPlugin = (providerId: string) => async (
15161599
}
15171600
}
15181601

1519-
const authorization = await authorizeAntigravity(projectId);
1520-
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
1521-
1522-
console.log("\nOAuth URL:\n" + authorization.url + "\n");
1523-
15241602
if (!isHeadless) {
15251603
await openBrowser(authorization.url);
15261604
}
15271605

15281606
if (listener) {
15291607
try {
1530-
const callbackUrl = await listener.waitForCallback();
1608+
const SOFT_TIMEOUT_MS = 30000;
1609+
const callbackPromise = listener.waitForCallback();
1610+
const timeoutPromise = new Promise<never>((_, reject) =>
1611+
setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS)
1612+
);
1613+
1614+
let callbackUrl: URL;
1615+
try {
1616+
callbackUrl = await Promise.race([callbackPromise, timeoutPromise]);
1617+
} catch (err) {
1618+
if (err instanceof Error && err.message === "SOFT_TIMEOUT") {
1619+
console.log("\n⏳ Automatic callback not received after 30 seconds.");
1620+
console.log("You can paste the redirect URL manually.\n");
1621+
console.log("OAuth URL (in case you need it again):");
1622+
console.log(authorization.url + "\n");
1623+
1624+
try {
1625+
await listener.close();
1626+
} catch {}
1627+
1628+
return promptManualOAuthInput(fallbackState);
1629+
}
1630+
throw err;
1631+
}
1632+
15311633
const params = extractOAuthCallbackParams(callbackUrl);
15321634
if (!params) {
15331635
return { type: "failed", error: "Missing code or state in callback URL" };
15341636
}
15351637

15361638
return exchangeAntigravity(params.code, params.state);
15371639
} catch (error) {
1640+
if (error instanceof Error && error.message !== "SOFT_TIMEOUT") {
1641+
return {
1642+
type: "failed",
1643+
error: error.message,
1644+
};
1645+
}
15381646
return {
15391647
type: "failed",
15401648
error: error instanceof Error ? error.message : "Unknown error",
15411649
};
15421650
} finally {
15431651
try {
15441652
await listener.close();
1545-
} catch {
1546-
// ignore
1547-
}
1653+
} catch {}
15481654
}
15491655
}
15501656

1551-
console.log("1. Open the URL below in your browser and complete Google sign-in.");
1552-
console.log(
1553-
"2. After approving, copy the full redirected localhost URL from the address bar.",
1554-
);
1555-
console.log("3. Paste it back here.");
1556-
1557-
const callbackInput = await promptOAuthCallbackValue(
1558-
"Paste the redirect URL (or just the code) here: ",
1559-
);
1560-
const params = parseOAuthCallbackInput(callbackInput, fallbackState);
1561-
if ("error" in params) {
1562-
return { type: "failed", error: params.error };
1563-
}
1564-
1565-
return exchangeAntigravity(params.code, params.state);
1657+
return promptManualOAuthInput(fallbackState);
15661658
})();
15671659

15681660
if (result.type === "failed") {
@@ -1662,8 +1754,10 @@ export const createAntigravityPlugin = (providerId: string) => async (
16621754
const existingStorage = await loadAccounts();
16631755
const existingCount = existingStorage?.accounts.length ?? 0;
16641756

1757+
const useManualFlow = isHeadless || shouldSkipLocalServer();
1758+
16651759
let listener: OAuthListener | null = null;
1666-
if (!isHeadless) {
1760+
if (!useManualFlow) {
16671761
try {
16681762
listener = await startOAuthListener();
16691763
} catch {
@@ -1674,8 +1768,12 @@ export const createAntigravityPlugin = (providerId: string) => async (
16741768
const authorization = await authorizeAntigravity(projectId);
16751769
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
16761770

1677-
if (!isHeadless) {
1678-
await openBrowser(authorization.url);
1771+
if (!useManualFlow) {
1772+
const browserOpened = await openBrowser(authorization.url);
1773+
if (!browserOpened) {
1774+
listener?.close().catch(() => {});
1775+
listener = null;
1776+
}
16791777
}
16801778

16811779
if (listener) {
@@ -1685,8 +1783,26 @@ export const createAntigravityPlugin = (providerId: string) => async (
16851783
"Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
16861784
method: "auto",
16871785
callback: async (): Promise<AntigravityTokenExchangeResult> => {
1786+
const CALLBACK_TIMEOUT_MS = 30000;
16881787
try {
1689-
const callbackUrl = await listener.waitForCallback();
1788+
const callbackPromise = listener.waitForCallback();
1789+
const timeoutPromise = new Promise<never>((_, reject) =>
1790+
setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS),
1791+
);
1792+
1793+
let callbackUrl: URL;
1794+
try {
1795+
callbackUrl = await Promise.race([callbackPromise, timeoutPromise]);
1796+
} catch (err) {
1797+
if (err instanceof Error && err.message === "CALLBACK_TIMEOUT") {
1798+
return {
1799+
type: "failed",
1800+
error: "Callback timeout - please use CLI with --no-browser flag for manual input",
1801+
};
1802+
}
1803+
throw err;
1804+
}
1805+
16901806
const params = extractOAuthCallbackParams(callbackUrl);
16911807
if (!params) {
16921808
return { type: "failed", error: "Missing code or state in callback URL" };
@@ -1695,13 +1811,10 @@ export const createAntigravityPlugin = (providerId: string) => async (
16951811
const result = await exchangeAntigravity(params.code, params.state);
16961812
if (result.type === "success") {
16971813
try {
1698-
// TUI flow adds to existing accounts (non-destructive)
16991814
await persistAccountPool([result], false);
17001815
} catch {
1701-
// ignore
17021816
}
17031817

1704-
// Show appropriate toast message
17051818
const newTotal = existingCount + 1;
17061819
const toastMessage = existingCount > 0
17071820
? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
@@ -1715,7 +1828,6 @@ export const createAntigravityPlugin = (providerId: string) => async (
17151828
},
17161829
});
17171830
} catch {
1718-
// TUI may not be available
17191831
}
17201832
}
17211833

@@ -1729,7 +1841,6 @@ export const createAntigravityPlugin = (providerId: string) => async (
17291841
try {
17301842
await listener.close();
17311843
} catch {
1732-
// ignore
17331844
}
17341845
}
17351846
},

src/plugin/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ const successResponse = `<!DOCTYPE html>
217217
reject(error);
218218
};
219219
server.once("error", handleError);
220-
server.listen(port, "127.0.0.1", () => {
220+
server.listen(port, () => {
221221
server.off("error", handleError);
222222
resolve();
223223
});

0 commit comments

Comments
 (0)