Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
Expand Down Expand Up @@ -582,7 +583,7 @@
"src/lib/memory/retrieval.ts": "1073",
"src/lib/tailscaleTunnel.ts": "1202",
"src/lib/usage/providerLimits.ts": "1013",
"src/shared/components/OAuthModal.tsx": "1134",
"src/shared/components/OAuthModal.tsx": "1146",
"src/shared/components/RequestLoggerV2.tsx": "1629",
"src/shared/components/analytics/charts.tsx": "1035",
"src/shared/services/cliRuntime.ts": "1122",
Expand Down
19 changes: 18 additions & 1 deletion src/app/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
];
Expand Down
28 changes: 27 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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");
}
51 changes: 46 additions & 5 deletions src/lib/oauth/providers/zed-hosted.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
import { getRuntimePorts } from "../../runtime/ports";
import {
createZedNativeAuthData,
parseZedCallbackPayload,
Expand All @@ -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,
Expand Down
27 changes: 22 additions & 5 deletions src/shared/components/OAuthModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/shared/components/OAuthModalPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/modelsDevSync-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,45 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => {
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
});

test("getModelsDevPricing memoizes until save/clear (#9685)", async () => {
const modelsDev = await importFresh("pricing-memo");
const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
modelsDev.saveModelsDevPricing(pricing);

const first = modelsDev.getModelsDevPricing();
const second = modelsDev.getModelsDevPricing();
assert.equal(first, second, "repeated reads must return the same memoized object");

// Mutating DB under the cache must not be visible until invalidation.
const db = core.getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run();
assert.equal(
modelsDev.getModelsDevPricing(),
first,
"raw SQL without save/clear must not bypass the memo"
);

modelsDev.clearModelsDevPricing();
assert.deepEqual(modelsDev.getModelsDevPricing(), {});

modelsDev.saveModelsDevPricing(pricing);
const afterSave = modelsDev.getModelsDevPricing();
assert.notEqual(afterSave, first, "save must invalidate the memo");
assert.equal(afterSave.openai["gpt-4o"].input, 2.5);

// Copilot review: DB reset must invalidate the memo so import/restore doesn't serve stale pricing.
const beforeReset = modelsDev.getModelsDevPricing();
core.resetDbInstance();
const afterReset = modelsDev.getModelsDevPricing();
assert.notEqual(
afterReset,
beforeReset,
"resetDbInstance must invalidate the memo (Copilot #10055)"
);
// Data is still on disk after resetDbInstance(), but the cache was cleared and re-read from fresh DB.
assert.equal(afterReset.openai["gpt-4o"].input, 2.5, "DB reset re-reads from fresh connection");
});

test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
const modelsDev = await importFresh("capabilities-storage");
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
Expand Down
17 changes: 14 additions & 3 deletions tests/unit/ui/oauth-callback-postmessage-scope.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import CallbackPage from "@/app/callback/page";
* Regression guard for ported upstream PR decolua/9router#998 (security):
* the OAuth callback page must never relay {code, state} to a wildcard
* postMessage target ("*"), as a hostile opener can read the code/state and
* complete the OAuth flow as the user. Only the same-origin parent and
* Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
* complete the OAuth flow as the user. Trusted targets are the same-origin
* parent, the loopback hostname variants of the same port (localhost vs
* 127.0.0.1 — Zed native-app redirects may land on the other spelling than the
* dashboard the modal was opened from; same port means the same OmniRoute
* server), and Codex's fixed loopback helper (127.0.0.1:1455).
*/
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
let container: HTMLDivElement;
Expand Down Expand Up @@ -81,7 +84,15 @@ describe("OAuth callback page — postMessage target origin scope (#998)", () =>
await Promise.resolve();
});

const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
const loopbackSamePort = window.location.port
? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
: [];
const trusted = new Set([
window.location.origin,
...loopbackSamePort,
"http://localhost:1455",
"http://127.0.0.1:1455",
]);
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
expect(targetOrigins.length).toBeGreaterThan(0);
for (const origin of targetOrigins) {
Expand Down
Loading