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
32 changes: 32 additions & 0 deletions bin/cli/commands/oauth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ async function openBrowser(url) {
}
}

// Mirrors src/lib/oauth/providers.ts::isLoopbackHostname — used here to detect
// when the redirect_uri the server resolved (and the authorize URL now
// advertises) points at a loopback address the CLI never binds a listener on
// (issue #12413). Returns false on an unparseable URI rather than throwing.
function isLoopbackHost(uri) {
try {
return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(new URL(uri).hostname);
} catch {
return false;
}
}

function printLoopbackRedirectWarning(providerId, redirectUri) {
process.stdout.write(
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
"listen on that port. Right after you approve, the browser is expected to\n" +
"show a connection error (e.g. \"This site can't be reached\" / \n" +
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
"from the address bar anyway and paste it below.\n"
);
if (providerId === "antigravity") {
process.stdout.write(
"Tip: `omniroute login antigravity` captures the code automatically and\n" +
"avoids that error page entirely.\n"
);
}
}

function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
Expand Down Expand Up @@ -110,6 +138,10 @@ async function runBrowserFlow(def, opts) {
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
const finalRedirectUri = returnedRedirectUri || redirectUri;

if (finalRedirectUri && isLoopbackHost(finalRedirectUri)) {
printLoopbackRedirectWarning(def.id, finalRedirectUri);
}

process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
if (opts.browser !== false) await openBrowser(url);
process.stdout.write(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)
161 changes: 161 additions & 0 deletions tests/unit/probe-12413-antigravity-oauth-redirect-hint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Readable } from "node:stream";

// Repro probe for issue #12413: `omniroute oauth start --provider antigravity`
// prints the Google authorize URL (which advertises
// redirect_uri=http://localhost:8080/callback) and then silently waits for a
// pasted callback URL/code — nothing in the CLI output warns the operator
// that their browser is about to land on a dead port (ERR_CONNECTION_REFUSED)
// and that seeing that error is expected, not a failure.
//
// This asserts the CLI's browser-flow instructions proactively mention the
// expected browser error (e.g. "can't be reached" / "connection refused" /
// "ERR_CONNECTION_REFUSED") BEFORE the user is sent to authorize.

function makeResp(data: unknown, status = 200) {
return {
ok: status < 400,
status,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
};
}

async function captureStdout(fn: () => Promise<void>) {
const chunks: string[] = [];
const orig = process.stdout.write.bind(process.stdout);
(process.stdout.write as unknown) = (c: string | Uint8Array) => {
chunks.push(typeof c === "string" ? c : Buffer.from(c).toString("utf8"));
return true;
};
try {
await fn();
} finally {
process.stdout.write = orig;
}
return chunks.join("");
}

function makeCmd() {
return { optsWithGlobals: () => ({ output: "json", quiet: true }) };
}

// readline's `close` fallback (bin/cli/io.mjs) only fires once per stdin EOF —
// reusing the real (already-ended) process.stdin across tests in the same file
// means the second readline.createInterface() never sees another `end`/`close`
// event and hangs forever. Swap in a fresh, already-ended Readable per test
// instead of calling `process.stdin.push(null)` on the shared real stream.
async function withEndedStdin<T>(fn: () => Promise<T>): Promise<T> {
const origStdin = process.stdin;
const fakeStdin = new Readable({ read() {} });
fakeStdin.push(null);
Object.defineProperty(process, "stdin", { value: fakeStdin, configurable: true });
try {
return await fn();
} finally {
Object.defineProperty(process, "stdin", { value: origStdin, configurable: true });
}
}

test("runOAuthStart browser flow warns antigravity users before the dead localhost:8080 redirect (#12413)", async () => {
const origFetch = globalThis.fetch;
const origExit = process.exit;
let exitErr: Error | null = null;

(globalThis.fetch as unknown) = (url: string) => {
if (url.includes("/api/oauth/antigravity/authorize")) {
return Promise.resolve(
makeResp({
authUrl:
"https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback",
codeVerifier: "verifier",
state: "state123",
redirectUri: "http://localhost:8080/callback",
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
};

(process.exit as unknown) = (code?: number) => {
exitErr = new Error(`exit ${code}`);
throw exitErr;
};

let out = "";
try {
const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs");
out = await withEndedStdin(() =>
captureStdout(async () => {
try {
await runOAuthStart({ provider: "antigravity", browser: false }, makeCmd());
} catch (e) {
if (e !== exitErr) throw e;
}
})
);
} finally {
globalThis.fetch = origFetch;
process.exit = origExit;
}

assert.ok(out.includes("8080"), "should print the advertised (dead) redirect port");

const mentionsExpectedBrowserError =
/can't be reached|connection refused|err_connection_refused|won't load|expected/i.test(out);
assert.ok(
mentionsExpectedBrowserError,
`expected the CLI to warn about the dead-redirect browser error BEFORE the user hits it, got:\n${out}`
);
});

test("runOAuthStart browser flow does NOT warn for a non-loopback redirect (claude-code)", async () => {
const origFetch = globalThis.fetch;
const origExit = process.exit;
let exitErr: Error | null = null;

(globalThis.fetch as unknown) = (url: string) => {
if (url.includes("/api/oauth/claude/authorize")) {
return Promise.resolve(
makeResp({
authUrl: "https://platform.claude.com/oauth/authorize?redirect_uri=fixed",
codeVerifier: "verifier",
state: "state123",
redirectUri: "https://platform.claude.com/oauth/code/callback",
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
};

(process.exit as unknown) = (code?: number) => {
exitErr = new Error(`exit ${code}`);
throw exitErr;
};

let out = "";
try {
const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs");
out = await withEndedStdin(() =>
captureStdout(async () => {
try {
await runOAuthStart({ provider: "claude-code", browser: false }, makeCmd());
} catch (e) {
if (e !== exitErr) throw e;
}
})
);
} finally {
globalThis.fetch = origFetch;
process.exit = origExit;
}

const mentionsExpectedBrowserError =
/can't be reached|connection refused|err_connection_refused|won't load/i.test(out);
assert.ok(
!mentionsExpectedBrowserError,
`did not expect a dead-redirect warning for a non-loopback provider, got:\n${out}`
);
});
Loading