fix(oauth): route zed-hosted native-app callback back to the dashboard port (#10517)

* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* Hide health-check excluded models from /v1/models catalog (#10026)

Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>

* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)

* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(oauth): route Zed hosted sign-in callback back to the dashboard port

Zed's native-app sign-in always redirects the browser to the loopback port
sent as native_app_port (hardcoded default 58443), where nothing listens:
the browser shows "site can't be reached" and the login looks broken even
though the token is in the URL. The manual paste fallback was broken too -
handleManualSubmit requires a ?code= param that Zed's callback
(user_id + access_token) never carries, so the flow could never complete.

- zed-hosted: derive native_app_port from the dashboard's own loopback
  port so the redirect lands back on OmniRoute; remote/LAN origins keep
  the old default port and the paste flow
- app root: forward ?user_id=...&access_token=... to the /callback relay
  instead of dropping the query string on the /dashboard redirect
- /callback relay: recognize the Zed payload (no code param) and relay the
  full URL as the exchange payload; allow postMessage to both loopback
  spellings (localhost/127.0.0.1) of the same port
- OAuthModal: zed-hosted popup auto-completes on true localhost; the
  manual paste path passes the full URL through to the exchange instead
  of erroring with "No authorization code found"
- manual input panel: zed-hosted-specific placeholder and hint
- tests: extend the postMessage scope guard with the loopback same-port
  trusted origins

* changelog: fragment for #10517

* fix(oauth): derive Zed native_app_port from server config, not browser scheme/port

resolveDashboardLoopbackPort() previously re-derived the dashboard's loopback
port from the browser-supplied redirectUri (window.location.port ||
protocol === "https:" ? "443" : "80"), which produced http://127.0.0.1:443/
native-app redirects when the dashboard was reached over HTTPS on its
implicit default port (e.g. behind a local TLS-terminating reverse proxy) -
a scheme/port mismatch, since Zed's own redirect is always plain http and
nothing serves plain HTTP on 443 in that scenario.

This code runs server-side (in the OAuth authorize API route), so once the
redirect URI's hostname is confirmed loopback it now uses the OmniRoute
process's own authoritative listening port via getRuntimePorts()
(OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead of re-deriving it from the
browser-observed scheme/port. Non-loopback (remote/LAN) redirect URIs still
return null and fall back to the manual paste flow.

Adds tests/unit/zed-hosted-loopback-port-derivation.test.ts (8 cases)
covering the port-derivation logic directly, including the HTTPS-default-port
mismatch scenario that motivated this fix, env-var precedence, IPv6 loopback,
non-loopback/remote fallback, and buildAuthUrl's native_app_port wiring.

Also rebaselines config/quality/file-size-baseline.json for OAuthModal.tsx's
own growth from this PR's earlier commit (1134->1149 gate units) - legitimate
zed-hosted callback wiring at the existing provider-switch chokepoint, not
extractable without a broader modal decomposition (tracked in #3501).

The live Zed OAuth handshake itself (root -> /callback -> OAuthModal exchange
against the real zed.dev endpoint) still needs a documented VPS smoke test
per Hard Rule #18; this fix covers the TDD-able port-derivation logic that
motivated the change.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
phatchau036
2026-08-18 21:56:00 +07:00
committed by GitHub
parent 59c8a9afc9
commit 72eff76910
10 changed files with 308 additions and 17 deletions

View File

@@ -23,11 +23,21 @@ export default function CallbackPage() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
let code = params.get("code");
const state = params.get("state");
const error = params.get("error");
const errorDescription = params.get("error_description");
// Zed native-app sign-in: the redirect carries user_id + access_token and no
// ?code= — the FULL URL is the exchange payload (zed-hosted's exchangeToken
// parses and RSA-decrypts it server-side). Rewritten here from `/` by the
// root page handler so the waiting OAuth modal receives it via the same
// postMessage/BroadcastChannel/localStorage relay as every other provider.
const zedAccessToken = params.get("access_token") || params.get("accessToken");
if (!code && zedAccessToken && (params.get("user_id") || params.get("userId"))) {
code = window.location.href;
}
const callbackData = {
code,
state,
@@ -63,6 +73,13 @@ export default function CallbackPage() {
// same-origin fallback when the opener was severed by COOP.
const trustedTargetOrigins = [
window.location.origin, // Same origin (dashboard popup mode).
// Loopback hostname variants of the same port: the dashboard may be open
// on 127.0.0.1:PORT while Zed's redirect (or vice versa) lands on
// localhost:PORT — both names are the operator's own machine, so the
// callback may be delivered to either. Same rationale as the 1455 entries.
...(window.location.port
? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
: []),
"http://localhost:1455", // Codex helper (fixed loopback port).
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
];

View File

@@ -1,5 +1,31 @@
import { redirect } from "next/navigation";
export default function InitPage() {
/**
* Root entry. Zed's native-app sign-in always redirects the browser to the
* loopback ROOT (`http://127.0.0.1:<dashboard-port>/?user_id=...&access_token=...`),
* ignoring any path — when the dashboard port is reused as native_app_port
* (see zed-hosted.ts), that redirect lands HERE. Forward the payload to the
* /callback relay (which postMessages it to the waiting OAuth modal) instead of
* the plain /dashboard redirect below, which would drop the query string.
*/
export default async function InitPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = (await searchParams) || {};
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
query.set(key, value);
} else if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string") query.append(key, item);
}
}
}
if (query.get("user_id") && query.get("access_token")) {
redirect(`/callback?${query.toString()}`);
}
redirect("/dashboard");
}

View File

@@ -1,4 +1,5 @@
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
import { getRuntimePorts } from "../../runtime/ports";
import {
createZedNativeAuthData,
parseZedCallbackPayload,
@@ -21,15 +22,55 @@ import {
*
* `code` at exchange time is the pasted native-app callback URL/query string
* (`http://127.0.0.1:<port>/?user_id=...&access_token=...`) — Zed always
* redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd
* send, so `redirectUri` here is unused by exchangeToken (kept only to
* satisfy OAuthModal's generic "session must have a redirectUri" guard).
* redirects to loopback + native_app_port, ignoring any path we'd send. When
* the dashboard itself listens on a loopback port, `buildAuthUrl` reuses it as
* native_app_port so the redirect lands back on OmniRoute (auto-completed via
* the /callback relay); otherwise the dead default port is used and the user
* completes the flow by pasting the browser's full URL.
*/
/**
* Extract the dashboard's loopback port so Zed's browser redirect can land back
* on OmniRoute itself. Zed always redirects to `http://127.0.0.1:<native_app_port>/`
* — it ignores any path/redirect_uri — so reusing the dashboard's own loopback
* port (e.g. 20128) turns the dead "site can't be reached" page into a loadable
* `/callback` relay (the root page forwards ?user_id=...&access_token=... there).
*
* The redirect URI only tells us WHICH HOSTNAME the browser used (loopback vs.
* LAN/remote) — its scheme and port reflect what the *browser* sees, which can
* differ from what the OmniRoute Node process actually listens on (e.g. a local
* TLS-terminating reverse proxy fronting the dashboard on 443 while the real
* process listens on 20128 in plain HTTP). Trusting the browser-supplied port
* previously produced `http://127.0.0.1:443/` redirects that nothing serves in
* plain HTTP. This runs server-side, so once the hostname is confirmed loopback
* (any scheme — Zed's own redirect is always plain http regardless of how the
* dashboard was reached), use the server's own authoritative listening port
* (`getRuntimePorts()`, sourced from OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead
* of re-deriving it from the client-observed scheme/port. Non-loopback redirect
* URIs (remote/LAN deployments) return null → keep the default port and rely on
* the manual paste flow.
*/
function resolveDashboardLoopbackPort(redirectUri: unknown): number | null {
try {
const url = new URL(String(redirectUri));
if (!/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return null;
const { dashboardPort } = getRuntimePorts();
return Number.isInteger(dashboardPort) && dashboardPort > 0 ? dashboardPort : null;
} catch {
return null;
}
}
// Exported for direct unit coverage of the port-derivation logic without
// exercising the live Zed OAuth handshake (see resolveDashboardLoopbackPort.test.ts).
export const __test__ = { resolveDashboardLoopbackPort };
export const zedHosted = {
config: ZED_HOSTED_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => {
const authData = createZedNativeAuthData(config);
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG, redirectUri?: string) => {
const nativeAppPort =
resolveDashboardLoopbackPort(redirectUri) || config.defaultNativeAppPort || 58443;
const authData = createZedNativeAuthData(config, { nativeAppPort });
return {
authUrl: authData.authUrl,
codeVerifier: authData.privateKeyVerifier,

View File

@@ -456,11 +456,17 @@ export default function OAuthModal({
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
// show an auth code instead of redirecting back to OmniRoute.
// Start directly in manual mode so users always have an input to paste code/url.
// zed-hosted's native-app sign-in always redirects the browser to a local
// 127.0.0.1:<port> callback that OmniRoute never listens on (the port is
// arbitrary and unrelated to the dashboard's own port) — nothing can
// auto-close the popup, so always show the manual paste-URL input.
if (provider === "claude" || provider === "cline" || provider === "zed-hosted") {
// zed-hosted's native-app sign-in redirects the browser to a local
// 127.0.0.1:<native_app_port> callback. On true localhost that port IS the
// dashboard's own (buildAuthUrl reuses it), so the redirect lands on the
// /callback relay and the popup flow auto-completes. Elsewhere (LAN/remote)
// the port is unreachable — nothing can auto-close the popup, so always
// show the manual paste-URL input.
if (
provider === "claude" ||
provider === "cline" ||
(provider === "zed-hosted" && !isTrueLocalhost)
) {
forceManual = true;
}
@@ -880,6 +886,17 @@ export default function OAuthModal({
}
const input = callbackUrl.trim();
// zed-hosted: the native-app callback (http://127.0.0.1:<port>/?user_id=...&access_token=...)
// carries no ?code= param — the FULL pasted URL (or JSON/query blob) is the
// payload. zed-hosted's exchangeToken parses user_id/access_token out of it
// and RSA-decrypts the token with the private key held in codeVerifier, so
// skip the generic code/state extraction below.
if (provider === "zed-hosted") {
await exchangeTokens(input, authData?.state || null);
return;
}
let code = null;
let state = authData?.state || null;
let errorParam = null;

View File

@@ -364,13 +364,23 @@ export function OAuthManualInputPanel({
code: (chunks) => <code className="font-mono">{chunks}</code>,
})}
</p>
{provider === "zed-hosted" && (
<p className="text-xs text-amber-500 mb-2">
After signing in, Zed redirects to a local address like{" "}
<code className="font-mono">http://127.0.0.1:&lt;port&gt;/?user_id=...</code> which the
browser may show as unreachable that is expected. Copy the FULL URL from the
browser address bar (the access token is inside it) and paste it above.
</p>
)}
<Input
value={callbackUrl}
onChange={(event) => onCallbackUrlChange(event.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: placeholderUrl
: provider === "zed-hosted"
? "http://127.0.0.1:<port>/?user_id=...&access_token=..."
: placeholderUrl
}
className="font-mono text-xs"
/>