Files
OmniRoute/tests/unit/oauth-lan-loopback-guidance.test.ts
Diego Rodrigues de Sa e Souza a61ed5deb1 fix(oauth): actionable guidance for LAN-origin loopback mismatches (codex + Antigravity) (#8463)
* 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>
2026-07-26 12:11:14 -03:00

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