From ea0c3f981aef1a035eba72e83e4bced3ee9a23d9 Mon Sep 17 00:00:00 2001 From: RHODIZSECURITY Date: Sat, 26 Sep 2026 11:29:06 -0500 Subject: [PATCH 1/3] fix(gui): offer pairing on authenticated remote hubs --- gui/src/App.tsx | 11 ++++-- gui/tests/remote-link-route.test.tsx | 50 +++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 0d28feeaa21..46cba1e5d09 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -20,7 +20,7 @@ import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconH import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; -import { apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; +import { adminTokenPromptAllowed, apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; import { readModelsTab, type ModelsTab } from "./pages/models-tab"; @@ -203,6 +203,11 @@ export default function App() { return () => controller.abort(); }, [page, sharedSessionReady, sharedBase]); const remoteWorkspaceAvailable = sharedSessionReady && remoteWorkspaceAvailableState; + // A standalone/hub dashboard exposed through an authenticated non-loopback origin can need a + // consent-bearing GUI session even though it is not a connected client. Remote Link requires + // that stronger principal, so offer the existing one-time pairing flow instead of a dead-end + // "sign in" warning. Other pages keep their ordinary admin-token flow unchanged. + const remotePairingRequired = page === "remote" && !sharedSessionReady && adminTokenPromptAllowed(); // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); @@ -552,7 +557,7 @@ export default function App() { {targetError && (
{t("connection.machineUnavailable")}
)} - {targets.connected && !sharedSessionReady && ( + {((targets.connected && !sharedSessionReady) || remotePairingRequired) && ( { setSharedSessionReady(true); setSharedSessionEpoch(epoch => epoch + 1); @@ -567,7 +572,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } - {page === "remote" && navigateToPage("remote-workspace")} />} + {page === "remote" && !remotePairingRequired && navigateToPage("remote-workspace")} />} {page === "remote-workspace" && navigateToPage("remote")} />} {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/tests/remote-link-route.test.tsx b/gui/tests/remote-link-route.test.tsx index df5c3ce58c6..8f0cfd4a0c8 100644 --- a/gui/tests/remote-link-route.test.tsx +++ b/gui/tests/remote-link-route.test.tsx @@ -20,17 +20,26 @@ function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } -function mountWindow(role: "standalone" | "hub"): void { - testWindow = new Window({ url: "http://localhost/#remote" }); +function mountWindow( + role: "standalone" | "hub", + options: { session?: boolean; url?: string; managementAuthRequired?: boolean } = {}, +): void { + const url = options.url ?? "http://localhost/#remote"; + const session = options.session ?? true; + testWindow = new Window({ url }); Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); const head = testWindow.document.head; - for (const [name, content] of [ + const metaEntries: Array = [ ["opencodex-runtime-role", role], - ["opencodex-session-token", "ocx_session_route_test"], - ["opencodex-session-csrf", "route-test-csrf"], - ["opencodex-session-origin", "http://localhost"], - ["opencodex-session-server-origin", "http://localhost"], - ] as const) { + ...(options.managementAuthRequired ? [["opencodex-management-auth-required", "1"]] as const : []), + ...(session ? [ + ["opencodex-session-token", "ocx_session_route_test"], + ["opencodex-session-csrf", "route-test-csrf"], + ["opencodex-session-origin", new URL(url).origin], + ["opencodex-session-server-origin", new URL(url).origin], + ] as const : []), + ]; + for (const [name, content] of metaEntries) { const meta = testWindow.document.createElement("meta"); meta.setAttribute("name", name); meta.setAttribute("content", content); @@ -109,3 +118,28 @@ for (const role of ["standalone", "hub"] as const) { expect(linkStatusReads).toBeGreaterThan(0); }); } + +test("an authenticated remote hub without a GUI session offers one-time pairing", async () => { + mountWindow("hub", { + session: false, + url: "https://opencodex.rhodiz.net/#remote", + managementAuthRequired: true, + }); + const { resetApiAuthFetchForTests, installApiAuthFetch } = await import("../src/api"); + resetApiAuthFetchForTests(); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); + const [{ createRoot }, { LanguageProvider }, { default: App }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/App"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => (container.textContent ?? "").includes("Connect this dashboard to the hub")); + expect(container.textContent).not.toContain("Sign in to the local dashboard session"); + expect(container.textContent).toContain('ocx gui pair --origin "https://opencodex.rhodiz.net"'); + expect(linkStatusReads).toBe(0); +}); From 5e3eaf14d54d5f6335ff4c3d632a8b9372dddb79 Mon Sep 17 00:00:00 2001 From: RHODIZSECURITY Date: Sat, 26 Sep 2026 13:47:20 -0500 Subject: [PATCH 2/3] fix(gui): require explicit auth declaration for remote pairing --- gui/src/App.tsx | 4 ++-- gui/src/api-targets.ts | 8 ++++++++ gui/tests/remote-link-route.test.tsx | 30 +++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 46cba1e5d09..819ced3e97b 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -20,7 +20,7 @@ import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconH import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Notice, Select, ToastNotice, type NoticeTone } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml, logoutApiSession, SESSION_UNAVAILABLE_EVENT } from "./api"; -import { adminTokenPromptAllowed, apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; +import { adminTokenPromptAllowed, managementAuthRequiredFromDocument, apiBaseForPlane, discoverApiTargets, isConnectedRuntime, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; import { readModelsTab, type ModelsTab } from "./pages/models-tab"; @@ -207,7 +207,7 @@ export default function App() { // consent-bearing GUI session even though it is not a connected client. Remote Link requires // that stronger principal, so offer the existing one-time pairing flow instead of a dead-end // "sign in" warning. Other pages keep their ordinary admin-token flow unchanged. - const remotePairingRequired = page === "remote" && !sharedSessionReady && adminTokenPromptAllowed(); + const remotePairingRequired = page === "remote" && !sharedSessionReady && managementAuthRequiredFromDocument(); // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts index f58d5efbdb7..3f38d687dd0 100644 --- a/gui/src/api-targets.ts +++ b/gui/src/api-targets.ts @@ -53,6 +53,14 @@ export function isConnectedRuntime(): boolean { * fall back to the role so a hub dashboard still works against a server that predates the * tag, and everything else reads as loopback — the safe default this file already uses. */ +export function managementAuthRequiredFromDocument(): boolean { + if (typeof document === "undefined") return false; + return document + .querySelector('meta[name="opencodex-management-auth-required"]') + ?.getAttribute("content") + ?.trim() === "1"; +} + export function adminTokenPromptAllowed(): boolean { if (typeof document !== "undefined") { const declared = document diff --git a/gui/tests/remote-link-route.test.tsx b/gui/tests/remote-link-route.test.tsx index 8f0cfd4a0c8..811453d2b51 100644 --- a/gui/tests/remote-link-route.test.tsx +++ b/gui/tests/remote-link-route.test.tsx @@ -22,7 +22,7 @@ function jsonResponse(body: unknown, status = 200): Response { function mountWindow( role: "standalone" | "hub", - options: { session?: boolean; url?: string; managementAuthRequired?: boolean } = {}, + options: { session?: boolean; url?: string; managementAuthTag?: "0" | "1" } = {}, ): void { const url = options.url ?? "http://localhost/#remote"; const session = options.session ?? true; @@ -31,7 +31,7 @@ function mountWindow( const head = testWindow.document.head; const metaEntries: Array = [ ["opencodex-runtime-role", role], - ...(options.managementAuthRequired ? [["opencodex-management-auth-required", "1"]] as const : []), + ...(options.managementAuthTag ? [["opencodex-management-auth-required", options.managementAuthTag]] as const : []), ...(session ? [ ["opencodex-session-token", "ocx_session_route_test"], ["opencodex-session-csrf", "route-test-csrf"], @@ -122,8 +122,8 @@ for (const role of ["standalone", "hub"] as const) { test("an authenticated remote hub without a GUI session offers one-time pairing", async () => { mountWindow("hub", { session: false, - url: "https://opencodex.rhodiz.net/#remote", - managementAuthRequired: true, + url: "https://hub.example.test/#remote", + managementAuthTag: "1", }); const { resetApiAuthFetchForTests, installApiAuthFetch } = await import("../src/api"); resetApiAuthFetchForTests(); @@ -140,6 +140,26 @@ test("an authenticated remote hub without a GUI session offers one-time pairing" }); await waitFor(() => (container.textContent ?? "").includes("Connect this dashboard to the hub")); expect(container.textContent).not.toContain("Sign in to the local dashboard session"); - expect(container.textContent).toContain('ocx gui pair --origin "https://opencodex.rhodiz.net"'); + expect(container.textContent).toContain('ocx gui pair --origin "https://hub.example.test"'); expect(linkStatusReads).toBe(0); }); + +for (const managementAuthTag of [undefined, "0"] as const) { + test(`a remote hub without an explicit auth-required declaration does not force pairing (${managementAuthTag ?? "missing"})`, async () => { + mountWindow("hub", { session: false, url: "https://hub.example.test/#remote", managementAuthTag }); + const { resetApiAuthFetchForTests, installApiAuthFetch } = await import("../src/api"); + resetApiAuthFetchForTests(); + installApiAuthFetch(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: window.fetch }); + const [{ createRoot }, { LanguageProvider }, { default: App }] = await Promise.all([ + import("react-dom/client"), import("../src/i18n/provider"), import("../src/App"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await waitFor(() => (container.textContent ?? "").includes("Sign in to the local dashboard session")); + expect(container.textContent).not.toContain("Connect this dashboard to the hub"); + expect(container.textContent).not.toContain("ocx gui pair --origin"); + }); +} From d50b47718e96e3ccccf8d6bece2165543795aae6 Mon Sep 17 00:00:00 2001 From: RHODIZSECURITY Date: Sat, 26 Sep 2026 14:23:50 -0500 Subject: [PATCH 3/3] fix(gui): advertise pairing on hub management ingress --- src/server/index/serve-options.ts | 10 +++++++++- tests/server/link-management-routes.test.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 97a7c674b70..3dbb514e398 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -208,6 +208,14 @@ export function trustedLoopbackForIngress(ingress: ServerIngress, hostname: stri || (ingress === "public" && isLoopbackHostname(hostname)); } +/** Whether the served GUI document must advertise an explicit pairing/auth requirement. */ +export function managementAuthRequiredForGuiDocument( + ingress: ServerIngress, + policy: Parameters[0], +): boolean { + return ingress === "hub-management" || isApiAuthRequired(policy); +} + /** * Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed * to the real upstream by the listener itself, so a request that lands here with another path @@ -1902,7 +1910,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { undefined, guiSessionCandidate ?? undefined, config.runtimeRole ?? "standalone", - isApiAuthRequired(policy), + managementAuthRequiredForGuiDocument(ingress, policy), ); if (guiFile) return guiFile; if (url.pathname === "/" && req.method === "GET") { diff --git a/tests/server/link-management-routes.test.ts b/tests/server/link-management-routes.test.ts index 100c31e7797..4c832080e30 100644 --- a/tests/server/link-management-routes.test.ts +++ b/tests/server/link-management-routes.test.ts @@ -8,7 +8,7 @@ import type { OcxConfig } from "../../src/types"; import type { LinkStore } from "../../src/link/store"; import type { LinkSupervisor } from "../../src/link/supervisor"; import type { SshRunner, SshChild, SshRunResult } from "../../src/link/ssh-runner"; -import { trustedLoopbackForIngress, type ServerIngress } from "../../src/server/index/serve-options"; +import { managementAuthRequiredForGuiDocument, trustedLoopbackForIngress, type ServerIngress } from "../../src/server/index/serve-options"; let temp = ""; @@ -116,6 +116,14 @@ describe("link management routes", () => { expect(trustedLoopbackForIngress("public", "::1")).toBe(true); }); + test("hub-management GUI documents advertise pairing even when the local bind policy is loopback", () => { + const loopbackPolicy = { hostname: "127.0.0.1" } as OcxConfig; + const remotePolicy = { hostname: "0.0.0.0" } as OcxConfig; + expect(managementAuthRequiredForGuiDocument("hub-management", loopbackPolicy)).toBe(true); + expect(managementAuthRequiredForGuiDocument("public", loopbackPolicy)).toBe(false); + expect(managementAuthRequiredForGuiDocument("public", remotePolicy)).toBe(true); + }); + test("issues and force-revokes a client-initiated link with the K2/K16 DTOs", async () => { temp = mkdtempSync(join(tmpdir(), "ocx-link-issue-")); const h = harness();