fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini oauth start (#12413) (#13265)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on #13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for #13278 and #13276, annotated and landed on #13243

⚠️ base-red inherited: #12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-11 22:06:18 -03:00
committed by GitHub
parent 2cb02d26c6
commit bdc2fb76f6
3 changed files with 194 additions and 0 deletions

View File

@@ -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,
@@ -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(

View File

@@ -0,0 +1 @@
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)

View File

@@ -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}`
);
});