mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
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:
committed by
GitHub
parent
2cb02d26c6
commit
bdc2fb76f6
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)
|
||||
161
tests/unit/probe-12413-antigravity-oauth-redirect-hint.test.ts
Normal file
161
tests/unit/probe-12413-antigravity-oauth-redirect-hint.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user