mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
* fix(oauth): explain the LAN-IP loopback mismatch with an actionable panel #8046 already stops the doomed login when a PKCE_CALLBACK_SERVER_PROVIDERS provider (codex / xai-oauth / grok-cli) is connected from a LAN IP, but it explained itself as one long English sentence rendered in the generic red "Connection failed" step. Two concrete problems with that: - the operator had to parse prose to work out WHICH ports to forward, and the command shipped with `<port>` / `<omniroute-host>` placeholders to resolve by hand; - it forwarded a single port. Both are required: the dashboard port is what makes the origin true-localhost (a LAN origin never reaches the callback-server branch at all), and the provider's fixed callback port is where the browser is actually sent back to. Forwarding either one alone still fails. buildPkceLoopbackMismatchHint() now returns the diagnosis as structured data with the detected host and both ports already filled in, and a dedicated OAuthLoopbackMismatchPanel renders it as: what happened -> how to fix, in three numbered steps with copy-to-clipboard fields. No "Try again" button — retrying the same origin fails identically. The panel yields to the paste-token tab so grok-cli (which is in both provider sets) never stacks the two views. The flat warning string stays exported for non-UI callers. docs: REMOTE-MODE.md gains a "Connecting Codex / Grok on a remote install" section with the fixed-callback table and the two-port tunnel, mirroring the existing Antigravity section. i18n: 9 new oauthModal keys, hand-written for en + pt-BR and propagated to the remaining 40 locales as `__MISSING__:` sentinels (runtime falls back to the clean English value per #7258). * fix(oauth): correct the Antigravity remote-login guidance and drop the stale i18n copy Same LAN-origin family as the codex fix in this branch, different mechanism and a worse failure mode. Google providers (antigravity / agy) have no fixed foreign port: OAuthModal builds `http://127.0.0.1:<dashboardPort>/callback`. On a LAN origin that 127.0.0.1 is the BROWSER's machine, and Google's firstparty/nativeapp consent only releases the code once the loopback is reachable from the approving browser. When it is not, the consent never redirects at all — it hangs. So unlike an ordinary provider there is no error page and no callback URL in the address bar. That made the existing copy actively wrong. `googleOAuthWarning` was corrected when the login helper shipped (#5203), but a changed English value does not invalidate existing translations and `i18n:sync-ui` only fills keys that are ABSENT, never ones that are STALE — so 39 of 43 locales (pt-BR, pt, es, de, fr, ja, zh-CN, …) kept the original "wait for the redirect, copy the full URL and paste it below", instructing a flow that cannot complete. The drift gate that should have caught this is a no-op: `check-translation-drift.mjs` needs `.i18n-state.json`, which is not in the repo, and it runs `--warn`. Because the key's MEANING changed, it is renamed rather than edited — a new key cannot inherit a stale translation. `googleOAuthWarning` is removed from all 43 locales and replaced by 7 `googleLoopback*` keys, hand-written for en + pt-BR and marked `__MISSING__:` elsewhere so the runtime falls back to correct English (#7258). UI: `OAuthGoogleLoopbackNotice` states what is happening and surfaces both real remedies with the detected host and port filled in — the local login helper (recommended; its blob is what the Step 2 field accepts) and a single-port SSH forward. It also REPLACES `remoteAccessInfo` for this family instead of stacking on top of it: that notice promises an error page whose URL you copy, true for ordinary providers and false here. `agy` deliberately gets no helper command. bin/cli/commands/login.mjs pins PROVIDER = "antigravity" and parsePastedCredentials() rejects a blob whose embedded provider does not match the route provider, so advertising the helper there would send the operator to a blob guaranteed to be refused. It keeps the tunnel path. Refactor: the shared `resolveDashboardPort` / `buildSshLocalForward` helpers move to `loopbackTunnel.ts`, used by both hint builders. The codex builder's behaviour is unchanged (its 11 tests still pass untouched). docs: REMOTE-MODE.md notes that the dashboard now surfaces the remedies, states that one forward is enough for Antigravity (contrasting the two-port codex case), and aligns Option B's command on 127.0.0.1 to match what the UI generates. --------- Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
163 lines
6.4 KiB
TypeScript
163 lines
6.4 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
import {
|
|
buildPkceLoopbackMismatchHint,
|
|
buildPkceLoopbackMismatchWarning,
|
|
} from "../../src/lib/oauth/utils/pkceLoopbackWarning";
|
|
|
|
// Follow-up to #8046. The LAN-IP guard already STOPS the doomed flow, but it
|
|
// surfaced its explanation as one long English sentence rendered in the generic
|
|
// red "Connection failed" step — the operator had to parse prose to work out
|
|
// that BOTH the dashboard port AND the provider's fixed callback port have to be
|
|
// forwarded, and the command shipped with `<port>`/`<omniroute-host>` placeholders
|
|
// they had to resolve by hand.
|
|
//
|
|
// buildPkceLoopbackMismatchHint() returns the same diagnosis as STRUCTURED data
|
|
// (what happened / why / the exact copy-pasteable command) so the modal can render
|
|
// an organized panel instead of a wall of text.
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const readSrc = (rel: string) => readFileSync(resolve(here, "../../src", rel), "utf8");
|
|
|
|
const LAN = { hostname: "192.168.0.15", port: "20128", protocol: "http:" };
|
|
|
|
test("hint reports the provider's fixed callback port alongside the dashboard port", () => {
|
|
const hint = buildPkceLoopbackMismatchHint("codex", LAN);
|
|
|
|
assert.equal(hint.provider, "codex");
|
|
assert.equal(hint.redirectUri, "http://localhost:1455/auth/callback");
|
|
assert.equal(hint.callbackPort, 1455);
|
|
assert.equal(hint.dashboardPort, "20128");
|
|
assert.equal(hint.dashboardHost, "192.168.0.15");
|
|
});
|
|
|
|
test("tunnel command forwards BOTH ports and reuses the detected host", () => {
|
|
const hint = buildPkceLoopbackMismatchHint("codex", LAN);
|
|
|
|
// The dashboard port makes the origin true-localhost (so the callback-server
|
|
// branch runs at all); the provider's fixed port is where the browser is sent
|
|
// back to. Forwarding only one of the two still fails.
|
|
assert.equal(
|
|
hint.tunnelCommand,
|
|
"ssh -L 20128:127.0.0.1:20128 -L 1455:127.0.0.1:1455 <user>@192.168.0.15"
|
|
);
|
|
assert.equal(hint.localDashboardUrl, "http://localhost:20128");
|
|
});
|
|
|
|
test("xai-oauth and grok-cli carry their own distinct callback ports", () => {
|
|
assert.equal(buildPkceLoopbackMismatchHint("xai-oauth", LAN).callbackPort, 56121);
|
|
assert.equal(buildPkceLoopbackMismatchHint("grok-cli", LAN).callbackPort, 56122);
|
|
assert.match(
|
|
buildPkceLoopbackMismatchHint("grok-cli", LAN).tunnelCommand,
|
|
/-L 56122:127\.0\.0\.1:56122/
|
|
);
|
|
});
|
|
|
|
test("unknown provider degrades to dashboard-only forwarding, never an invalid flag", () => {
|
|
const hint = buildPkceLoopbackMismatchHint("some-future-pkce-provider", LAN);
|
|
|
|
assert.equal(hint.callbackPort, null);
|
|
assert.equal(hint.tunnelCommand, "ssh -L 20128:127.0.0.1:20128 <user>@192.168.0.15");
|
|
assert.doesNotMatch(hint.tunnelCommand, /null|undefined|NaN/);
|
|
});
|
|
|
|
test("an empty location.port resolves from the protocol instead of leaking ':'", () => {
|
|
const plain = buildPkceLoopbackMismatchHint("codex", {
|
|
hostname: "10.0.0.9",
|
|
port: "",
|
|
protocol: "http:",
|
|
});
|
|
assert.equal(plain.dashboardPort, "80");
|
|
assert.equal(plain.localDashboardUrl, "http://localhost:80");
|
|
|
|
const tls = buildPkceLoopbackMismatchHint("codex", {
|
|
hostname: "10.0.0.9",
|
|
port: "",
|
|
protocol: "https:",
|
|
});
|
|
assert.equal(tls.dashboardPort, "443");
|
|
});
|
|
|
|
test("a dashboard already on the callback port emits a single -L flag", () => {
|
|
const hint = buildPkceLoopbackMismatchHint("codex", {
|
|
hostname: "172.16.4.4",
|
|
port: "1455",
|
|
protocol: "http:",
|
|
});
|
|
|
|
assert.equal(hint.tunnelCommand, "ssh -L 1455:127.0.0.1:1455 <user>@172.16.4.4");
|
|
});
|
|
|
|
test("the flat warning string is still exported for non-UI callers", () => {
|
|
// Kept so the API route / logs keep a one-line form; the modal uses the hint.
|
|
const msg = buildPkceLoopbackMismatchWarning("codex");
|
|
assert.match(msg, /localhost:1455/);
|
|
assert.match(msg, /LAN IP/i);
|
|
});
|
|
|
|
test("OAuthModal renders the structured panel instead of the generic error step", () => {
|
|
const modal = readSrc("shared/components/OAuthModal.tsx");
|
|
|
|
assert.match(
|
|
modal,
|
|
/else if \(isLocalhost\) \{[\s\S]{0,300}buildPkceLoopbackMismatchHint/,
|
|
"the isLocalhost arm of PKCE_CALLBACK_SERVER_PROVIDERS must build the structured hint"
|
|
);
|
|
assert.match(
|
|
modal,
|
|
/setStep\("loopback-mismatch"\)/,
|
|
"the guard must route to its own dedicated step, not the generic red error step"
|
|
);
|
|
assert.match(
|
|
modal,
|
|
/OAuthLoopbackMismatchPanel/,
|
|
"the dedicated step must render the organized panel component"
|
|
);
|
|
});
|
|
|
|
test("the panel yields to the paste-token tab, like the generic error step does", () => {
|
|
// grok-cli sits in BOTH PKCE_CALLBACK_SERVER_PROVIDERS and TOKEN_PASTE_PROVIDERS,
|
|
// so a user who hits the LAN guard and then switches to "Import auth.json" would
|
|
// otherwise see the paste form and this panel stacked on top of each other.
|
|
const modal = readSrc("shared/components/OAuthModal.tsx");
|
|
|
|
assert.match(
|
|
modal,
|
|
/step === "loopback-mismatch" &&[^\n]*!showPasteToken/,
|
|
"the loopback-mismatch step must be hidden while the paste-token tab is active"
|
|
);
|
|
});
|
|
|
|
test("the panel is i18n-driven — no hardcoded English prose in the component", () => {
|
|
const panels = readSrc("shared/components/OAuthModalPanels.tsx");
|
|
const panel = panels.slice(panels.indexOf("export function OAuthLoopbackMismatchPanel"));
|
|
|
|
assert.ok(panel.length > 0, "OAuthLoopbackMismatchPanel should exist in OAuthModalPanels.tsx");
|
|
assert.match(panel, /t\("loopbackMismatch/, "panel copy must come from the oauthModal catalog");
|
|
// The command itself must be copy-pasteable, like the other panels' fields.
|
|
assert.match(panel, /copy\(/, "the tunnel command needs a copy-to-clipboard affordance");
|
|
});
|
|
|
|
test("en + pt-BR catalogs define every loopbackMismatch key the panel reads", () => {
|
|
const panels = readSrc("shared/components/OAuthModalPanels.tsx");
|
|
const used = new Set(
|
|
[...panels.matchAll(/t(?:\.rich)?\("(loopbackMismatch[A-Za-z0-9]*)"/g)].map((m) => m[1])
|
|
);
|
|
assert.ok(used.size >= 6, `expected the panel to use several keys, saw ${used.size}`);
|
|
|
|
for (const locale of ["en", "pt-BR"]) {
|
|
const cat = JSON.parse(
|
|
readFileSync(resolve(here, `../../src/i18n/messages/${locale}.json`), "utf8")
|
|
);
|
|
for (const key of used) {
|
|
assert.ok(
|
|
typeof cat.oauthModal?.[key] === "string" && cat.oauthModal[key].length > 0,
|
|
`${locale}.json is missing oauthModal.${key}`
|
|
);
|
|
}
|
|
}
|
|
});
|