diff --git a/devlog/_plan/260827_remote_hub/assets/gui-p4-dashboard.png b/devlog/_plan/260827_remote_hub/assets/gui-p4-dashboard.png new file mode 100644 index 0000000000..72523a8f1a Binary files /dev/null and b/devlog/_plan/260827_remote_hub/assets/gui-p4-dashboard.png differ diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 73648675e8..d6167ab2cc 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -15,15 +15,15 @@ import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; -import { installApiAuthFetch } from "./api"; +import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml } from "./api"; +import { 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"; import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; -installApiAuthFetch(); - type Theme = "light" | "dark" | "system"; const PAGE_TKEY: Record = { @@ -40,6 +40,9 @@ const PAGE_TKEY: Record = { }; const API_BASE = import.meta.env.VITE_API_BASE || ""; +const INITIAL_TARGETS = standaloneApiTargets(API_BASE); +configureApiTargets(INITIAL_TARGETS); +installApiAuthFetch(); const THEME_KEY = "ocx-theme"; /** @@ -101,6 +104,41 @@ export default function App() { const [theme, setTheme] = useState(readStoredTheme); const { locale, setLocale } = useI18n(); const t = useT(); + const [targets, setTargets] = useState(INITIAL_TARGETS); + // Standalone starts settled: there is nothing to discover, so nothing to wait for. + // Gating the page on discovery made a plain install show remote-hub loading copy before + // its own dashboard, for a feature the operator never enabled. + const [targetsSettled, setTargetsSettled] = useState(() => !isConnectedRuntime()); + const [targetError, setTargetError] = useState(false); + const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); + + useEffect(() => { + const controller = new AbortController(); + void discoverApiTargets(API_BASE, controller.signal).then(async next => { + configureApiTargets(next); + setTargets(next); + if (next.connected && !hasApiSession("shared")) { + try { + const response = await fetch(next.shared.bootstrapPath, { + cache: "no-store", + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]), + }); + if (response.ok) installApiSessionFromHtml("shared", await response.text()); + } catch { /* pairing form remains available */ } + } + if (controller.signal.aborted) return; + setSharedSessionReady(hasApiSession("shared")); + setTargetError(false); + setTargetsSettled(true); + }).catch(() => { + if (controller.signal.aborted) return; + setTargetError(true); + setTargetsSettled(true); + }); + return () => controller.abort(); + }, []); + const machineBase = apiBaseForPlane("machine", targets); + const sharedBase = apiBaseForPlane("shared", targets); // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); @@ -126,14 +164,14 @@ export default function App() { }, [theme]); const healthPoll = useKeyedClientResource( - `app-healthz:${API_BASE}`, - [], + `app-healthz:${machineBase}`, + [machineBase, targetsSettled], async (signal) => { - const res = await fetch(`${API_BASE}/healthz`, { signal }); + const res = await fetch(`${machineBase}/healthz`, { signal }); if (!res.ok) return null; return readRuntimeVersion(await res.json()); }, - { pollMs: 30_000 }, + { pollMs: 30_000, enabled: targetsSettled }, ); const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); @@ -175,15 +213,16 @@ export default function App() { // sharing a controller — the backend is already single-flight, so what is missing // is invalidation, not mutual exclusion. const [codexRestartEpoch, setCodexRestartEpoch] = useState(0); - const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(API_BASE, { + const { restarting: codexRestarting, restart: handleCodexRestart } = useCodexRestart(sharedBase, { onSettled: () => setCodexRestartEpoch(epoch => epoch + 1), }); const handleStop = async () => { - if (!confirm(t("dash.stopConfirm"))) return; + if (!confirm(t(targets.connected ? "connection.disconnectConfirm" : "dash.stopConfirm"))) return; setStopping(true); - const outcome = await requestProxyStop(API_BASE, { + const outcome = await requestProxyStop(machineBase, { formatFailure: status => t("dash.stopFailed", { status: String(status) }), + mode: targets.connected ? "client" : "standalone", }); // Refusals and restore failures return normally instead of dropping the connection. // In both cases the proxy did not reach a clean-stop result, so re-enable the control @@ -214,7 +253,7 @@ export default function App() { {brand}
{ // The update dialog lives on the dashboard maintenance panel. Deep-link to // `#dashboard/update` and let the dashboard own the check/run flow — no @@ -328,16 +367,34 @@ export default function App() { detailsLabel={t("errorBoundary.details")} reloadLabel={t("errorBoundary.reload")} > - {page === "dashboard" && } - {page === "startup" && } - {page === "providers" && } - {page === "models" && } - {page === "subagents" && } - {page === "logs" && } - {page === "usage" && } - {page === "storage" && } - {page === "codex-set" && } - {page === "integrations" && } + {!targetsSettled ? ( +
{t("connection.discovering")}
+ ) : ( + <> + {/* + A failed discovery is a banner, not a replacement. It used to take over the + whole body, so a slow or restarting proxy cost a standalone user their + dashboard over a plane they never turned on. The requests that actually + need the machine plane report their own errors. + */} + {targetError && ( +
{t("connection.machineUnavailable")}
+ )} + {targets.connected && !sharedSessionReady && ( + setSharedSessionReady(true)} /> + )} + {page === "dashboard" && } + {page === "startup" && } + {page === "providers" && } + {page === "models" && } + {page === "subagents" && } + {page === "logs" && } + {page === "usage" && } + {page === "storage" && } + {page === "codex-set" && } + {page === "integrations" && } + + )} diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts new file mode 100644 index 0000000000..7a1a1d17d6 --- /dev/null +++ b/gui/src/api-targets.ts @@ -0,0 +1,164 @@ +export type ApiPlane = "machine" | "shared"; +export type SharedTransport = "same-origin" | "direct" | "relay"; + +/** + * The runtime role the server stated in the served document, or null when it said nothing. + * + * Read without removing the tag: unlike the session meta, which is consumed once so a + * credential does not linger in the DOM, the role is non-secret and may be read again. + */ +function runtimeRoleFromDocument(): string | null { + if (typeof document === "undefined") return null; + const meta = document.querySelector('meta[name="opencodex-runtime-role"]'); + return meta?.getAttribute("content")?.trim() || null; +} + +/** + * Did the server say this proxy is running as a connected client? + * + * Anything else — standalone, hub, an older server that sends no tag, a separately hosted + * GUI, the Vite dev server — is treated as "not connected", which is the state that needs + * no remote-hub work and makes no remote-hub requests. + */ +export function isConnectedRuntime(): boolean { + return runtimeRoleFromDocument() === "client"; +} + +export interface ApiTarget { + id: ApiPlane; + baseUrl: string; + serverOrigin: string; + bootstrapPath: string; + transport: SharedTransport; +} + +export interface ApiTargets { + connected: boolean; + machine: ApiTarget; + shared: ApiTarget; + apiKeyId?: string; +} + +export interface MachineStatusV1 { + mode: "client"; + connected: true; + machineBase: string; + sharedBase: string; + sharedServerOrigin: string; + managementTransport: "direct" | "relay"; + apiKeyId: string; + protocolVersion: 1; + connectedAt: string; + catalogSyncedAt?: string; + hubReachability: "unknown" | "online" | "offline" | "unauthorized"; +} + +function trimBase(value: string): string { + return value.replace(/\/+$/, ""); +} + +function absoluteBase(value: string): URL { + return new URL(value || "/", window.location.href); +} + +function canonicalOrigin(value: string): string | null { + try { + const url = new URL(value); + if ((url.protocol !== "http:" && url.protocol !== "https:") + || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return null; + return url.origin; + } catch { + return null; + } +} + +function target(id: ApiPlane, baseUrl: string, serverOrigin: string, transport: SharedTransport): ApiTarget { + const base = trimBase(baseUrl); + return { id, baseUrl: base, serverOrigin, bootstrapPath: `${base}/opencodex-session`, transport }; +} + +export function standaloneApiTargets(initialBase: string): ApiTargets { + const resolved = absoluteBase(initialBase); + const baseUrl = trimBase(initialBase); + return { + connected: false, + machine: target("machine", baseUrl, resolved.origin, "same-origin"), + shared: target("shared", baseUrl, resolved.origin, "same-origin"), + }; +} + +function validStatus(value: unknown): value is MachineStatusV1 { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const row = value as Record; + return row.mode === "client" && row.connected === true && row.protocolVersion === 1 + && (row.managementTransport === "direct" || row.managementTransport === "relay") + && typeof row.machineBase === "string" && typeof row.sharedBase === "string" + && typeof row.sharedServerOrigin === "string" && typeof row.apiKeyId === "string" + && row.apiKeyId.trim().length > 0 && typeof row.connectedAt === "string"; +} + +export function relayUrlForPath(shared: ApiTarget, path: string): string { + if (shared.transport !== "relay" || (!path.startsWith("/api/") && path !== "/opencodex-session")) { + throw new TypeError("path is not eligible for the fixed hub relay"); + } + if (path.startsWith("//") || path.includes("\\") || /%(?:2f|5c|2e)/i.test(path) || path.includes("#")) { + throw new TypeError("encoded or authority relay path refused"); + } + return `${trimBase(shared.baseUrl)}${path}`; +} + +export function targetsFromMachineStatus(initialBase: string, status: MachineStatusV1): ApiTargets { + if (!validStatus(status)) throw new TypeError("machine status response is invalid"); + const initial = standaloneApiTargets(initialBase); + const machineOrigin = canonicalOrigin(status.machineBase); + const sharedOrigin = canonicalOrigin(status.sharedServerOrigin); + if (!machineOrigin || machineOrigin !== initial.machine.serverOrigin || !sharedOrigin) { + throw new TypeError("machine status target origins are invalid"); + } + let advertisedShared: URL; + try { advertisedShared = new URL(status.sharedBase); } catch { throw new TypeError("machine status shared target is invalid"); } + if (advertisedShared.username || advertisedShared.password || advertisedShared.search || advertisedShared.hash) { + throw new TypeError("machine status shared target is invalid"); + } + if (status.managementTransport === "direct") { + if (advertisedShared.origin !== sharedOrigin || advertisedShared.pathname !== "/") { + throw new TypeError("machine status direct target is inconsistent"); + } + } else if (advertisedShared.origin !== machineOrigin || advertisedShared.pathname !== "/api/machine/hub-relay") { + throw new TypeError("machine status relay target is inconsistent"); + } + const machine = target("machine", trimBase(initialBase), machineOrigin, "same-origin"); + const shared = status.managementTransport === "relay" + ? target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay") + : target("shared", sharedOrigin, sharedOrigin, "direct"); + return { connected: true, machine, shared, apiKeyId: status.apiKeyId }; +} + +export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string { + return targets[plane].baseUrl; +} + +export async function discoverApiTargets(initialBase: string, signal?: AbortSignal): Promise { + const standalone = standaloneApiTargets(initialBase); + // Standalone asks nothing. + // + // The server states the role in the served document, so a user who never enabled remote + // hub makes no request to a remote-hub endpoint — not even one that 404s. Discovery used + // to run unconditionally and infer standalone FROM that 404, which meant every dashboard + // load probed a feature the operator had not turned on. + // + // A missing tag means standalone too: an older server, a separately hosted GUI, or the + // Vite dev server all read as "no remote topology", which is the safe default. + if (runtimeRoleFromDocument() !== "client") return standalone; + let response: Response; + try { + response = await fetch(`${standalone.machine.baseUrl}/api/machine/status`, { signal, cache: "no-store" }); + } catch (error) { + throw new Error("local machine plane unavailable", { cause: error }); + } + if (response.status === 404) return standalone; + if (!response.ok) throw new Error(`local machine plane refused discovery (${response.status})`); + const body = await response.json().catch(() => null); + if (!validStatus(body)) throw new Error("local machine plane returned invalid status"); + return targetsFromMachineStatus(initialBase, body); +} diff --git a/gui/src/api.ts b/gui/src/api.ts index cce2f98951..e6893d4c2f 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,299 +1,261 @@ import { promptForAdminToken, type AdminTokenVerifier } from "./admin-token-dialog"; import { createBoundedFetch } from "./bounded-fetch"; +import { standaloneApiTargets, type ApiPlane, type ApiTarget, type ApiTargets } from "./api-targets"; -let installed = false; -/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ -let resolutionInFlight: Promise | null = null; -/** Unwrapped fetch captured at install time — used for session re-bootstrap so the - * bootstrap document request itself never enters the 401 handling path. */ -let rawFetch: typeof fetch | null = null; -/** - * After the user cancels (or submits blank) once, suppress further prompts for this page - * lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex). - * A full reload clears module state and allows prompting again. - */ -let promptCancelled = false; - -type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; -let requestAdminToken: AdminTokenPrompt = promptForAdminToken; - -/** - * Document path re-fetched to mint a fresh loopback GUI session (server injects meta tags). - * Deliberately NOT "/": the Vite dev server owns that route for the app shell, so the dev - * proxy forwards this dedicated extensionless path to the backend with the original host. - */ -const SESSION_REBOOTSTRAP_PATH = "/opencodex-session"; -/** Safe authenticated read used to validate a raw admin token before closing the sign-in form. */ +const LEGACY_TOKEN_KEY = "opencodex-api-token"; const ADMIN_TOKEN_VALIDATION_PATH = "/api/settings"; - -/** - * The silent re-bootstrap must fail fast: every /api/* request queues behind the - * shared resolution, so an unbounded bootstrap hangs the whole dashboard (H2). - */ const SESSION_REBOOTSTRAP_TIMEOUT_MS = 10_000; -let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; - -/** - * Whole-resolution watchdog. The bootstrap bound covers a well-behaved fetch; this - * covers everything else — a fetch that never honors the abort, a prompt path that - * pends without settling, any surprise inside the shared body. Without it one stuck - * resolution pins every /api/* waiter for the page lifetime, which is the exact - * failure this module exists to kill. - * - * Scope note: the watchdog races the BOOTSTRAP CALL ONLY, never the admin-token - * prompt. The prompt is user-controlled and unbounded by design; while its body - * pends, later waves join the same resolution, which is what keeps a single dialog - * on screen (promptForAdminToken has no singleton guard — a watchdog that fired - * during the prompt would stack a fresh modal every cycle). - */ const RESOLUTION_WATCHDOG_MS = 15_000; -let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; +const MACHINE_SESSION_HEADER = "X-OpenCodex-Machine-Session"; +const MACHINE_GUI_ORIGIN_HEADER = "X-OpenCodex-Machine-GUI-Origin"; +const MACHINE_CSRF_HEADER = "X-OpenCodex-Machine-CSRF-Token"; + +interface ApiSessionState { + token: string | null; + csrfToken: string | null; + browserOrigin: string | null; + serverOrigin: string | null; +} -function needsApiAuth(input: RequestInfo | URL): boolean { - try { - const raw = input instanceof Request ? input.url : String(input); - const url = new URL(raw, window.location.href); - const admittedOrigin = memoryToken?.startsWith("ocx_session_") - ? memorySessionServerOrigin - : window.location.origin; - // A session is destination-bound. Third-party origins get neither credentials - // nor the local admin-token prompt. - if (!admittedOrigin || url.origin !== admittedOrigin) return false; - return url.pathname.startsWith("/api/"); - } catch { - return false; - } +interface TargetRuntime { + target: ApiTarget; + session: ApiSessionState; + resolutionInFlight: Promise | null; + promptCancelled: boolean; } -/** Legacy sessionStorage key from pre-memory auth — wiped once on install, never read. */ -const LEGACY_TOKEN_KEY = "opencodex-api-token"; +type AdminTokenPrompt = (verifyToken: AdminTokenVerifier) => Promise; +type RebootstrapResult = { kind: "minted"; token: string } | { kind: "unavailable" } | { kind: "failed" }; -/** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ -let memoryToken: string | null = null; -let memoryCsrfToken: string | null = null; -let memorySessionBrowserOrigin: string | null = null; -let memorySessionServerOrigin: string | null = null; +let installed = false; +let rawFetch: typeof fetch | null = null; +let configuredTargets: ApiTargets | null = null; +let requestAdminToken: AdminTokenPrompt = promptForAdminToken; +let rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; +let resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; +const runtimes = new Map(); -function readToken(): string | null { - return memoryToken; +function blankSession(): ApiSessionState { + return { token: null, csrfToken: null, browserOrigin: null, serverOrigin: null }; } -function storeToken(token: string): void { - memoryToken = token; +function ensureTargets(): ApiTargets { + if (!configuredTargets) configureApiTargets(standaloneApiTargets("")); + return configuredTargets!; } -function clearToken(): void { - memoryToken = null; - memoryCsrfToken = null; - memorySessionBrowserOrigin = null; - memorySessionServerOrigin = null; +function sameTarget(left: ApiTarget, right: ApiTarget): boolean { + return left.baseUrl === right.baseUrl && left.serverOrigin === right.serverOrigin && left.transport === right.transport; } -function takeMetaContent(name: string): string | null { - const element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null; - const content = element?.content.trim() || null; - element?.remove(); - return content; +export function configureApiTargets(targets: ApiTargets): void { + configuredTargets = targets; + for (const plane of ["machine", "shared"] as const) { + const current = runtimes.get(plane); + runtimes.set(plane, current && sameTarget(current.target, targets[plane]) + ? { ...current, target: targets[plane] } + : { target: targets[plane], session: blankSession(), resolutionInFlight: null, promptCancelled: false }); + } } -function loadInjectedSession(): void { - const token = takeMetaContent("opencodex-session-token"); - const csrfToken = takeMetaContent("opencodex-session-csrf"); - const browserOrigin = takeMetaContent("opencodex-session-origin"); - const serverOrigin = takeMetaContent("opencodex-session-server-origin"); - storeSession(token, csrfToken, browserOrigin, serverOrigin, window.location.origin); +function runtime(plane: ApiPlane): TargetRuntime { + ensureTargets(); + return runtimes.get(plane)!; } -/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */ -function clearTokenIfCurrent(expected: string | null): void { - if (expected != null && readToken() === expected) clearToken(); +function clearSessionIfCurrent(plane: ApiPlane, expected: string | null): void { + const state = runtime(plane); + if (expected !== null && state.session.token === expected) state.session = blankSession(); } -/** Validate and store a server-minted GUI session; rejects anything bound to another origin. */ function storeSession( + plane: ApiPlane, token: string | null, csrfToken: string | null, browserOrigin: string | null, serverOrigin: string | null, - expectedServerOrigin: string, ): boolean { - if ( - !token?.startsWith("ocx_session_") - || !csrfToken - || browserOrigin !== window.location.origin - || serverOrigin !== expectedServerOrigin - ) { - clearToken(); + const state = runtime(plane); + if (!token?.startsWith("ocx_session_") || !csrfToken + || browserOrigin !== window.location.origin || serverOrigin !== state.target.serverOrigin) { + state.session = blankSession(); return false; } - memoryToken = token; - memoryCsrfToken = csrfToken; - memorySessionBrowserOrigin = browserOrigin; - memorySessionServerOrigin = serverOrigin; + state.session = { token, csrfToken, browserOrigin, serverOrigin }; + state.promptCancelled = false; return true; } -/** Read one named meta tag out of a served HTML document (attribute order varies). */ +export function hasApiSession(plane: ApiPlane): boolean { + return Boolean(runtime(plane).session.token?.startsWith("ocx_session_")); +} + +function takeMetaContent(name: string): string | null { + const element = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement | null; + const content = element?.content.trim() || null; + element?.remove(); + return content; +} + +function loadInjectedSession(): void { + const values = { + token: takeMetaContent("opencodex-session-token"), + csrf: takeMetaContent("opencodex-session-csrf"), + browser: takeMetaContent("opencodex-session-origin"), + server: takeMetaContent("opencodex-session-server-origin"), + }; + for (const plane of ["machine", "shared"] as const) { + if (runtime(plane).target.serverOrigin === values.server) { + storeSession(plane, values.token, values.csrf, values.browser, values.server); + } + } +} + function metaContentFromHtml(html: string, name: string): string | null { for (const tag of html.match(/]*>/gi) ?? []) { - const nameMatch = tag.match(/\bname="([^"]+)"/i); + const nameMatch = tag.match(/\bname=["']([^"']+)["']/i); if (nameMatch?.[1] !== name) continue; - const contentMatch = tag.match(/\bcontent="([^"]*)"/i); + const contentMatch = tag.match(/\bcontent=["']([^"']*)["']/i); return contentMatch?.[1]?.trim() || null; } return null; } -/** - * Silently renew the GUI session from a freshly served document. Loopback servers mint - * short-lived sessions into the HTML on every page load, so an expired session (5-minute - * TTL) or one invalidated by a proxy restart is replaced without ever asking the user for - * a token. - * - * Tri-state by design: only a definitive refusal ("unavailable": 4xx, or an OK - * document without valid session meta — the non-loopback shape) may fall through to - * the admin-token prompt. Anything transient — timeout, abort, network error, 5xx - * from an intermediate proxy — is "failed", which settles this wave as an ordinary - * request failure and lets the next poll retry. Mapping a transient failure to the - * prompt would pop a credential modal on a loopback dashboard that needs no token. - */ -type RebootstrapResult = - | { kind: "minted"; token: string } - | { kind: "unavailable" } - | { kind: "failed" }; +export function installApiSessionFromHtml(plane: ApiPlane, html: string): boolean { + return storeSession( + plane, + metaContentFromHtml(html, "opencodex-session-token"), + metaContentFromHtml(html, "opencodex-session-csrf"), + metaContentFromHtml(html, "opencodex-session-origin"), + metaContentFromHtml(html, "opencodex-session-server-origin"), + ); +} -async function reBootstrapSessionToken(): Promise { - if (!rawFetch) return { kind: "failed" }; - const bounded = createBoundedFetch(rebootstrapTimeoutMs); - try { - const response = await rawFetch(SESSION_REBOOTSTRAP_PATH, { cache: "no-store", signal: bounded.signal }); - if (!response.ok) { - // Only a definitive refusal is "unavailable"; 5xx and everything else is transient. - return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; - } - const html = await response.text(); - let responseOrigin: string; - try { - if (!response.url) throw new TypeError("bootstrap response URL is missing"); - responseOrigin = new URL(response.url).origin; - } catch { - clearToken(); - return { kind: "unavailable" }; - } - const stored = storeSession( - metaContentFromHtml(html, "opencodex-session-token"), - metaContentFromHtml(html, "opencodex-session-csrf"), - metaContentFromHtml(html, "opencodex-session-origin"), - metaContentFromHtml(html, "opencodex-session-server-origin"), - responseOrigin, - ); - const token = readToken(); - if (stored && token) return { kind: "minted", token }; - return { kind: "unavailable" }; - } catch { - return { kind: "failed" }; - } finally { - bounded.clear(); - } +function clearLegacySessionToken(): void { + try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); } catch { /* storage may be disabled */ } } -async function verifyAdminToken(token: string): ReturnType { - if (!rawFetch) return "unavailable"; - try { - const [input, init] = withToken(ADMIN_TOKEN_VALIDATION_PATH, { cache: "no-store" }, token); - const response = await rawFetch(input, init); - if (response.status === 401) return "rejected"; - return response.ok ? "accepted" : "unavailable"; - } catch { - return "unavailable"; - } +function targetAbsoluteBase(target: ApiTarget): URL { + return new URL(target.baseUrl || "/", window.location.href); } -function clearLegacySessionToken(): void { +function targetMatchesUrl(target: ApiTarget, url: URL): boolean { + const base = targetAbsoluteBase(target); + if (url.origin !== base.origin) return false; + const prefix = base.pathname.replace(/\/$/, ""); + return prefix === "" || url.pathname === prefix || url.pathname.startsWith(`${prefix}/`); +} + +function relativeTargetPath(target: ApiTarget, url: URL): string | null { + if (!targetMatchesUrl(target, url)) return null; + const base = targetAbsoluteBase(target).pathname.replace(/\/$/, ""); + return url.pathname.slice(base.length) || "/"; +} + +function classify(input: RequestInfo | URL): { plane: ApiPlane; bootstrap: boolean } | null { + let url: URL; try { - sessionStorage.removeItem(LEGACY_TOKEN_KEY); - } catch { - /* session storage may be disabled */ - } + url = new URL(input instanceof Request ? input.url : String(input), window.location.href); + } catch { return null; } + const targets = ensureTargets(); + if (url.href === new URL(targets.shared.bootstrapPath, window.location.href).href) return { plane: "shared", bootstrap: true }; + if (targets.shared.transport === "relay" && targetMatchesUrl(targets.shared, url)) return { plane: "shared", bootstrap: false }; + const machinePath = relativeTargetPath(targets.machine, url); + if (machinePath?.startsWith("/api/machine/")) return { plane: "machine", bootstrap: false }; + const sharedPath = relativeTargetPath(targets.shared, url); + if (sharedPath?.startsWith("/api/")) return { plane: "shared", bootstrap: false }; + if (url.href === new URL(targets.machine.bootstrapPath, window.location.href).href) return { plane: "machine", bootstrap: true }; + return null; } -function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { +function sessionHeaders(plane: ApiPlane, input: RequestInfo | URL, init?: RequestInit, overrideToken?: string | null): Headers { + const state = runtime(plane); const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); - headers.set("X-OpenCodex-API-Key", token); - if (memorySessionBrowserOrigin && memorySessionServerOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) { - const raw = input instanceof Request ? input.url : String(input); - let destinationOrigin: string | null = null; - try { destinationOrigin = new URL(raw, window.location.href).origin; } catch { /* leave null */ } - if (destinationOrigin !== memorySessionServerOrigin) return [input, init]; - headers.set("X-OpenCodex-GUI-Origin", memorySessionBrowserOrigin); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (method !== "GET" && method !== "HEAD") { - headers.set("X-OpenCodex-CSRF-Token", memoryCsrfToken); - } + const token = overrideToken === undefined ? state.session.token : overrideToken; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + if (token) headers.set("X-OpenCodex-API-Key", token); + if (token?.startsWith("ocx_session_") && state.session.browserOrigin && state.session.csrfToken) { + headers.set("X-OpenCodex-GUI-Origin", state.session.browserOrigin); + if (method !== "GET" && method !== "HEAD") headers.set("X-OpenCodex-CSRF-Token", state.session.csrfToken); } + if (plane === "shared" && state.target.transport === "relay") { + const machine = runtime("machine").session; + if (machine.token) headers.set(MACHINE_SESSION_HEADER, machine.token); + if (machine.browserOrigin) headers.set(MACHINE_GUI_ORIGIN_HEADER, machine.browserOrigin); + if (method !== "GET" && method !== "HEAD" && machine.csrfToken) headers.set(MACHINE_CSRF_HEADER, machine.csrfToken); + } + return headers; +} + +function withAuth( + plane: ApiPlane, + input: RequestInfo | URL, + init?: RequestInit, + overrideToken?: string | null, +): [RequestInfo | URL, RequestInit | undefined] { + const headers = sessionHeaders(plane, input, init, overrideToken); if (input instanceof Request) return [new Request(input, { headers }), init ? { ...init, headers } : undefined]; return [input, { ...init, headers }]; } -/** - * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard - * fan-out opens at most one credential dialog per /api request wave (#647). Re-reads - * memoryToken before prompting so waiters that wake after another request already stored a token - * do not re-prompt. - */ -async function resolveTokenAfter401(failedToken: string | null, callerSignal?: AbortSignal): Promise { - if (promptCancelled) return null; - if (callerSignal?.aborted) return null; - if (!resolutionInFlight) { +async function reBootstrapSessionToken(plane: ApiPlane): Promise { + if (!rawFetch) return { kind: "failed" }; + const state = runtime(plane); + const bounded = createBoundedFetch(rebootstrapTimeoutMs); + try { + const [input, init] = withAuth(plane, state.target.bootstrapPath, { cache: "no-store", signal: bounded.signal }, null); + const response = await rawFetch(input, init); + if (!response.ok) return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; + const html = await response.text(); + if (!installApiSessionFromHtml(plane, html)) return { kind: "unavailable" }; + return { kind: "minted", token: runtime(plane).session.token! }; + } catch { return { kind: "failed" }; } + finally { bounded.clear(); } +} + +async function verifyAdminToken(plane: ApiPlane, token: string): ReturnType { + if (!rawFetch) return "unavailable"; + try { + const state = runtime(plane); + const [input, init] = withAuth(plane, `${state.target.baseUrl}${ADMIN_TOKEN_VALIDATION_PATH}`, { cache: "no-store" }, token); + const response = await rawFetch(input, init); + if (response.status === 401) return "rejected"; + return response.ok ? "accepted" : "unavailable"; + } catch { return "unavailable"; } +} + +async function resolveTokenAfter401(plane: ApiPlane, failedToken: string | null, callerSignal?: AbortSignal): Promise { + const state = runtime(plane); + if (state.promptCancelled || callerSignal?.aborted) return null; + if (!state.resolutionInFlight) { const body = (async () => { - if (promptCancelled) return null; - const current = readToken(); + const current = state.session.token; if (current && current !== failedToken) return current; - - // The watchdog races the bootstrap call only — never the prompt below. When - // it wins, the wave fails and the conditional clear lets the NEXT 401 start - // a fresh resolution instead of joining the zombie. let watchdog: ReturnType | undefined; const renewed = await Promise.race([ - reBootstrapSessionToken(), - new Promise((resolve) => { - watchdog = setTimeout(() => resolve({ kind: "failed" }), resolutionWatchdogMs); - }), + reBootstrapSessionToken(plane), + new Promise(resolve => { watchdog = setTimeout(() => resolve({ kind: "failed" }), resolutionWatchdogMs); }), ]).finally(() => clearTimeout(watchdog)); if (renewed.kind === "minted") return renewed.token; - // Transient bootstrap failure: this wave fails and the next 401 re-arms a - // fresh resolution (the finally clears resolutionInFlight). No prompt. if (renewed.kind === "failed") return null; - - // User-controlled and unbounded: later waves join this pending body, which - // is what keeps exactly one prompt dialog on screen. - const prompted = await requestAdminToken(verifyAdminToken); + const prompted = await requestAdminToken(token => verifyAdminToken(plane, token)); if (prompted) { - storeToken(prompted); + state.session = { token: prompted, csrfToken: null, browserOrigin: null, serverOrigin: state.target.serverOrigin }; return prompted; } - promptCancelled = true; + state.promptCancelled = true; return null; })(); - const tracked = body.finally(() => { - // Only clear if nobody replaced us — a late settle must not wipe a newer - // in-flight resolution. (Async callback: tracked is assigned long before - // this can run.) - if (resolutionInFlight === tracked) resolutionInFlight = null; - }); - resolutionInFlight = tracked; + const tracked = body.finally(() => { if (state.resolutionInFlight === tracked) state.resolutionInFlight = null; }); + state.resolutionInFlight = tracked; } - - if (!callerSignal) return resolutionInFlight; - // Per-caller race: an abort unwinds THIS caller only — a dead caller must not - // cancel the shared resolution other waiters still need. The listener is removed - // whether the race resolves by token or by abort, so waiters never accumulate. + if (!callerSignal) return state.resolutionInFlight; let onAbort: (() => void) | undefined; - const aborted = new Promise((resolve) => { + const aborted = new Promise(resolve => { onAbort = () => resolve(null); callerSignal.addEventListener("abort", onAbort, { once: true }); }); - return Promise.race([resolutionInFlight, aborted]).finally(() => { + return Promise.race([state.resolutionInFlight, aborted]).finally(() => { if (onAbort) callerSignal.removeEventListener("abort", onAbort); }); } @@ -301,62 +263,45 @@ async function resolveTokenAfter401(failedToken: string | null, callerSignal?: A export function installApiAuthFetch(): void { if (installed) return; installed = true; - // Drop any leftover XSS-readable token; new tokens stay memory-only (no read/migrate). clearLegacySessionToken(); + ensureTargets(); loadInjectedSession(); const originalFetch = window.fetch.bind(window); rawFetch = originalFetch; window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - if (!needsApiAuth(input)) return originalFetch(input, init); - - const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); - const token = readToken(); - const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init]; + const classified = classify(input); + if (!classified) return originalFetch(input, init); + const state = runtime(classified.plane); + const token = state.session.token; + const [firstInput, firstInit] = withAuth(classified.plane, input, init); const response = await originalFetch(firstInput, firstInit); - if (response.status !== 401) return response; - - // Another request may have stored a token while this one was in flight (or while prompt blocked). - const refreshed = readToken(); + if (classified.bootstrap || response.status !== 401) return response; + const refreshed = state.session.token; if (refreshed && refreshed !== token) { - const [retryInput, retryInit] = withToken(input, init, refreshed); + const [retryInput, retryInit] = withAuth(classified.plane, input, init); const retry = await originalFetch(retryInput, retryInit); if (retry.status !== 401) return retry; - clearTokenIfCurrent(refreshed); - } else { - clearTokenIfCurrent(token); - } - - const nextToken = await resolveTokenAfter401(token, callerSignal ?? undefined); + clearSessionIfCurrent(classified.plane, refreshed); + } else clearSessionIfCurrent(classified.plane, token); + const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const nextToken = await resolveTokenAfter401(classified.plane, token, callerSignal ?? undefined); if (!nextToken) return response; - - const [retryInput, retryInit] = withToken(input, init, nextToken); + const [retryInput, retryInit] = withAuth(classified.plane, input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearTokenIfCurrent(nextToken); + if (retry.status === 401) clearSessionIfCurrent(classified.plane, nextToken); return retry; }; } -/** Test-only: allow a fresh `installApiAuthFetch()` in the same module instance. */ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = promptForAdminToken): void { installed = false; - memoryToken = null; - memoryCsrfToken = null; - memorySessionBrowserOrigin = null; - memorySessionServerOrigin = null; - resolutionInFlight = null; rawFetch = null; - promptCancelled = false; + configuredTargets = null; + runtimes.clear(); requestAdminToken = adminTokenPrompt; rebootstrapTimeoutMs = SESSION_REBOOTSTRAP_TIMEOUT_MS; resolutionWatchdogMs = RESOLUTION_WATCHDOG_MS; } -/** Test-only: shrink the re-bootstrap deadline so timeout paths run in milliseconds. */ -export function setRebootstrapTimeoutForTests(ms: number): void { - rebootstrapTimeoutMs = ms; -} - -/** Test-only: shrink the whole-resolution watchdog so zombie paths run in milliseconds. */ -export function setResolutionWatchdogForTests(ms: number): void { - resolutionWatchdogMs = ms; -} +export function setRebootstrapTimeoutForTests(ms: number): void { rebootstrapTimeoutMs = ms; } +export function setResolutionWatchdogForTests(ms: number): void { resolutionWatchdogMs = ms; } diff --git a/gui/src/components/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index 46d670a68e..311a3faa73 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -17,8 +17,6 @@ import { } from "../../i18n/log-guard-state-labels"; import { formatBytes } from "../../format-bytes"; -const API_BASE = import.meta.env.VITE_API_BASE || ""; - export interface StorageLargestEntry { path: string; bytes: number; @@ -370,6 +368,7 @@ function CodexLogGuardUnavailablePanel({ locale, t }: { locale: Locale; t: TFn } export interface StorageWorkspaceProps { report: StorageReport; locale: Locale; + apiBase?: string; logGuardBusy?: boolean; onLogGuardAction?: (action: CodexLogGuardAction) => void; } @@ -398,6 +397,7 @@ type GenerationScopedCompaction = { export default function StorageWorkspace({ report, locale, + apiBase = "", logGuardBusy = false, onLogGuardAction, }: StorageWorkspaceProps) { @@ -460,7 +460,7 @@ export default function StorageWorkspace({ body: JSON.stringify({ mode: action.mode }), } : {}), }; - const response = await fetch(`${API_BASE}/api/storage/codex-logs/${suffix}`, init); + const response = await fetch(`${apiBase}/api/storage/codex-logs/${suffix}`, init); if (!response.ok) { const errorPayload = await response.json().catch(() => ({})) as Record; setLogGuardError({ generation, message: mutationErrorLabel(locale, errorPayload.error) }); @@ -512,7 +512,7 @@ export default function StorageWorkspace({ // The mutation has already succeeded. Refresh is deliberately best effort so // a transient GET/JSON failure cannot be presented as a failed compaction. try { - const refreshed = await fetch(`${API_BASE}/api/storage/codex-logs`); + const refreshed = await fetch(`${apiBase}/api/storage/codex-logs`); if (refreshed.ok) { const payload = await refreshed.json() as CodexLogGuardReport; setLogGuardOverride({ generation, report: payload }); diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts new file mode 100644 index 0000000000..fc82035085 --- /dev/null +++ b/gui/src/connect-pairing-transport.ts @@ -0,0 +1,38 @@ +import { installApiSessionFromHtml } from "./api"; +import type { ApiTarget } from "./api-targets"; + +const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; + +/** + * Exchange a pairing code for a shared-plane session. + * + * Separate module from the form that calls it so neither file mixes a component export with + * a plain one. That mix is what `react-refresh/only-export-components` flags, and the two + * have no reason to share a file: the transport is testable without React and the form has + * no logic beyond calling it. + */ +export async function submitConnectPairing( + target: ApiTarget, + grant: string, + fetchImpl?: typeof fetch, +): Promise { + const code = grant.trim(); + if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); + // Resolved at CALL time, not as a default parameter. + // + // `installApiAuthFetch` replaces `window.fetch` with the wrapper that attaches plane + // credentials — including the machine-session headers a relayed exchange needs to reach + // the hub. A default of `fetch` binds whatever the global was when this module was + // evaluated, which on the relay path is the unwrapped original, so the request went out + // unauthenticated and the relay refused it. + const send = fetchImpl ?? ((input, init) => window.fetch(input, init)); + const response = await send(target.bootstrapPath, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "text/html" }, + body: JSON.stringify({ grant: code }), + }); + if (!response.ok) throw new Error("pairing_refused"); + const html = await response.text(); + if (!installApiSessionFromHtml("shared", html)) throw new Error("pairing_response_invalid"); + return true; +} diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts new file mode 100644 index 0000000000..00e48abd7a --- /dev/null +++ b/gui/src/connect-pairing.ts @@ -0,0 +1,55 @@ +import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; +import type { ApiTarget } from "./api-targets"; +import { useT } from "./i18n/shared"; +import { submitConnectPairing } from "./connect-pairing-transport"; + +export function ConnectPairingForm({ + target, + onConnected, +}: { + target: ApiTarget; + onConnected: () => void; +}) { + const t = useT(); + const [grant, setGrant] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + setBusy(true); + setError(false); + try { + await submitConnectPairing(target, grant); + onConnected(); + } catch { + setError(true); + } finally { + setBusy(false); + } + }; + + return createElement("section", { className: "card connect-pairing", "aria-labelledby": "connect-pairing-title" }, + createElement("h2", { id: "connect-pairing-title" }, t("connection.pairing.title")), + createElement("p", null, t(target.transport === "relay" ? "connection.pairing.relayWarning" : "connection.pairing.body")), + createElement("form", { onSubmit: submit, className: "api-form-row" }, + createElement("label", { htmlFor: "connect-pairing-code", className: "field-label" }, t("connection.pairing.code")), + createElement("input", { + id: "connect-pairing-code", + name: "pairingCode", + value: grant, + onChange: (event: ChangeEvent) => setGrant(event.currentTarget.value), + autoComplete: "off", + spellCheck: false, + disabled: busy, + className: "input mono", + "aria-invalid": error || undefined, + "aria-describedby": error ? "connect-pairing-error" : undefined, + }), + createElement("button", { type: "submit", className: "btn btn-primary", disabled: busy || !grant.trim() }, + t(busy ? "connection.pairing.submitting" : "connection.pairing.submit")), + error ? createElement("p", { id: "connect-pairing-error", className: "alert alert-err", role: "alert" }, t("connection.pairing.error")) : null, + ), + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 61b46f2223..18db779899 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2321,4 +2321,30 @@ export const de: Record = { "models.aliasAuto": "automatisch", "models.aliasUser": "benutzerdefiniert", "models.aliasStale": "veraltet", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fb25f211fb..dba1942dfd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2355,6 +2355,32 @@ export const en = { "models.aliasAuto": "auto", "models.aliasUser": "user", "models.aliasStale": "stale", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 16a6ab693d..e06519c2ee 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2308,4 +2308,30 @@ export const fr: Record = { "models.aliasAuto": "auto", "models.aliasUser": "utilisateur", "models.aliasStale": "obsolète", + "connection.discovering": "Détection des cibles locale et partagée…", + "connection.machineUnavailable": "Le plan machine local est indisponible. Les requêtes partagées n'ont pas été redirigées localement.", + "connection.disconnect": "Déconnecter du hub", + "connection.disconnectConfirm": "Déconnecter cette machine du hub et la redémarrer en mode autonome ?", + "connection.pairing.title": "Connecter ce tableau de bord au hub", + "connection.pairing.body": "Collez le code d'association à usage unique créé sur le hub.", + "connection.pairing.relayWarning": "Ce code passe par le relais fixe du hub. Le relais ne peut pas viser un autre hôte.", + "connection.pairing.code": "Code d'association à usage unique", + "connection.pairing.submit": "Connecter", + "connection.pairing.submitting": "Connexion…", + "connection.pairing.error": "Le code a été refusé ou a expiré. Il reste saisi pour vérification.", + "connection.machine.title": "Cette machine", + "connection.machine.shimHealthy": "Le shim Codex est opérationnel.", + "connection.machine.shimNeedsAttention": "Le shim Codex nécessite une intervention.", + "connection.machine.repairShim": "Réparer le shim", + "connection.machine.removeShim": "Supprimer le shim", + "connection.clients.title": "Clients connectés", + "connection.clients.none": "Aucun état client disponible", + "connection.clients.sync": "Synchroniser", + "connection.clients.syncing": "Synchronisation…", + "usage.source.connected": "Source : utilisation du hub", + "usage.source.local": "Source : usage.jsonl local", + "usage.scope.label": "Portée de l'utilisation", + "usage.scope.machine": "Cette machine", + "usage.scope.hub": "Tout le hub", + "usage.hubOffline": "L'utilisation du hub est indisponible. Les données locales n'ont pas été substituées.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index df16c0562e..9b5de33c86 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2342,4 +2342,30 @@ export const ja: Record = { "models.aliasAuto": "自動", "models.aliasUser": "ユーザー", "models.aliasStale": "古い", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index dfa0803dd9..9fc2969c3f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2343,4 +2343,30 @@ export const ko: Record = { "models.aliasAuto": "자동", "models.aliasUser": "사용자", "models.aliasStale": "오래됨", + "connection.discovering": "로컬 및 공유 대상을 확인하는 중…", + "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", + "connection.disconnect": "허브 연결 해제", + "connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?", + "connection.pairing.title": "이 대시보드를 허브에 연결", + "connection.pairing.body": "허브에서 만든 일회용 페어링 코드를 붙여 넣으세요.", + "connection.pairing.relayWarning": "이 코드는 고정 허브 릴레이로 교환됩니다. 릴레이 목적지는 다른 호스트로 바꿀 수 없습니다.", + "connection.pairing.code": "일회용 페어링 코드", + "connection.pairing.submit": "연결", + "connection.pairing.submitting": "연결 중…", + "connection.pairing.error": "페어링 코드가 거부되었거나 만료되었습니다. 확인할 수 있도록 입력값은 유지했습니다.", + "connection.machine.title": "이 머신", + "connection.machine.shimHealthy": "Codex shim이 정상입니다.", + "connection.machine.shimNeedsAttention": "Codex shim을 확인해야 합니다.", + "connection.machine.repairShim": "shim 복구", + "connection.machine.removeShim": "shim 제거", + "connection.clients.title": "연결된 클라이언트", + "connection.clients.none": "클라이언트 상태 없음", + "connection.clients.sync": "지금 동기화", + "connection.clients.syncing": "동기화 중…", + "usage.source.connected": "출처: 허브 사용량", + "usage.source.local": "출처: 로컬 usage.jsonl", + "usage.scope.label": "사용량 범위", + "usage.scope.machine": "이 머신", + "usage.scope.hub": "허브 전체", + "usage.hubOffline": "허브 사용량을 불러올 수 없습니다. 로컬 사용량으로 대체하지 않았습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 36153cd22f..f5aa05bb69 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2344,4 +2344,30 @@ export const ru: Record = { "models.aliasAuto": "авто", "models.aliasUser": "пользователь", "models.aliasStale": "устарел", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f567faa70d..82b0a6c575 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2344,4 +2344,30 @@ export const tr: Record = { "models.aliasAuto": "otomatik", "models.aliasUser": "kullanıcı", "models.aliasStale": "eski", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 616fa1044c..6974aaabc6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2306,4 +2306,30 @@ export const zhTW: Record = { "models.aliasAuto": "自動", "models.aliasUser": "使用者", "models.aliasStale": "過期", + "connection.discovering": "正在探索本機與共享目標…", + "connection.machineUnavailable": "本機機器平面無法使用。共享請求未改用本機資料。", + "connection.disconnect": "中斷 Hub 連線", + "connection.disconnectConfirm": "要中斷此機器與 Hub 的連線,並以獨立模式重新啟動嗎?", + "connection.pairing.title": "將此儀表板連接到 Hub", + "connection.pairing.body": "貼上在 Hub 建立的一次性配對碼。", + "connection.pairing.relayWarning": "此代碼透過固定 Hub 轉送交換,無法重新導向其他主機。", + "connection.pairing.code": "一次性配對碼", + "connection.pairing.submit": "連接", + "connection.pairing.submitting": "連接中…", + "connection.pairing.error": "配對碼遭拒或已過期。輸入內容已保留供檢查。", + "connection.machine.title": "此機器", + "connection.machine.shimHealthy": "Codex shim 狀態正常。", + "connection.machine.shimNeedsAttention": "Codex shim 需要處理。", + "connection.machine.repairShim": "修復 shim", + "connection.machine.removeShim": "移除 shim", + "connection.clients.title": "已連接的用戶端", + "connection.clients.none": "沒有用戶端狀態", + "connection.clients.sync": "立即同步", + "connection.clients.syncing": "同步中…", + "usage.source.connected": "來源:Hub 使用量", + "usage.source.local": "來源:本機 usage.jsonl", + "usage.scope.label": "使用量範圍", + "usage.scope.machine": "此機器", + "usage.scope.hub": "整個 Hub", + "usage.hubOffline": "Hub 使用量無法使用,未以本機使用量替代。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 733ce1728e..2fe77941a1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2342,4 +2342,30 @@ export const zh: Record = { "models.aliasAuto": "自动", "models.aliasUser": "用户", "models.aliasStale": "过期", + "connection.discovering": "Discovering local and shared targets…", + "connection.machineUnavailable": "The local machine plane is unavailable. Shared requests were not redirected locally.", + "connection.disconnect": "Disconnect from hub", + "connection.disconnectConfirm": "Disconnect this machine from the hub and restart it in standalone mode?", + "connection.pairing.title": "Connect this dashboard to the hub", + "connection.pairing.body": "Paste the one-time pairing code created on the hub.", + "connection.pairing.relayWarning": "This code is exchanged through the fixed hub relay. The relay cannot be redirected to another host.", + "connection.pairing.code": "One-time pairing code", + "connection.pairing.submit": "Connect", + "connection.pairing.submitting": "Connecting…", + "connection.pairing.error": "The pairing code was refused or expired. The code was left in place so you can check it.", + "connection.machine.title": "This machine", + "connection.machine.shimHealthy": "Codex shim is healthy.", + "connection.machine.shimNeedsAttention": "Codex shim needs attention.", + "connection.machine.repairShim": "Repair shim", + "connection.machine.removeShim": "Remove shim", + "connection.clients.title": "Connected clients", + "connection.clients.none": "No client status available", + "connection.clients.sync": "Sync now", + "connection.clients.syncing": "Syncing…", + "usage.source.connected": "Source: hub usage", + "usage.source.local": "Source: local usage.jsonl", + "usage.scope.label": "Usage scope", + "usage.scope.machine": "This machine", + "usage.scope.hub": "Hub-wide", + "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", }; diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 63da22f558..4e3ba89426 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -39,7 +39,7 @@ function tabMark(tab: IntegrationTab): string | null { return INTEGRATION_MARKS[tab] ?? null; } -export default function Integrations({ apiBase }: { apiBase: string }) { +export default function Integrations({ apiBase, machineApiBase = apiBase, connected = false }: { apiBase: string; machineApiBase?: string; connected?: boolean }) { const t = useT(); const [tab, setTab] = useState(readIntegrationTab); /* @@ -51,8 +51,34 @@ export default function Integrations({ apiBase }: { apiBase: string }) { () => new Set([readIntegrationTab()]), ); const tabRefs = useRef | null>(null); + const [machineClients, setMachineClients] = useState([]); + const [machineSyncing, setMachineSyncing] = useState(false); if (tabRefs.current === null) tabRefs.current = new Map(); + useEffect(() => { + if (!connected) return; + const controller = new AbortController(); + void fetch(`${machineApiBase}/api/machine/clients`, { signal: controller.signal }) + .then(response => response.ok ? response.json() : null) + .then((value: { selectedClients?: unknown } | null) => { + if (!controller.signal.aborted && Array.isArray(value?.selectedClients)) { + setMachineClients(value.selectedClients.filter((item): item is string => typeof item === "string")); + } + }).catch(() => {}); + return () => controller.abort(); + }, [connected, machineApiBase]); + + const syncMachine = async () => { + setMachineSyncing(true); + try { + await fetch(`${machineApiBase}/api/machine/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + } finally { setMachineSyncing(false); } + }; + /* * Every tab change goes through here, whether it came from a click or from * the browser's own history. Accumulating the mounted set in an effect @@ -104,6 +130,13 @@ export default function Integrations({ apiBase }: { apiBase: string }) {

{t("nav.integrations")}

{t("integrations.subtitle")}

+ {connected && ( +
+ {t("connection.clients.title")} + {machineClients.length > 0 ? machineClients.join(", ") : t("connection.clients.none")} + +
+ )}
{TABS.map(definition => ( diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index 53b997fac3..c9e5ef2aaf 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -70,7 +70,7 @@ function deriveCodexRuntimeNotice( return { warning: null, fix: null }; } -export default function Startup({ apiBase }: { apiBase: string }) { +export default function Startup({ apiBase, machineApiBase = apiBase, connected = false }: { apiBase: string; machineApiBase?: string; connected?: boolean }) { const { t } = useI18n(); const cacheKey = `${STARTUP_PAGE_CACHE_PREFIX}${apiBase}`; const cached = useMemo(() => readSessionListCache(cacheKey), [cacheKey]); @@ -89,6 +89,33 @@ export default function Startup({ apiBase }: { apiBase: string }) { const [runtimeNoticePending, setRuntimeNoticePending] = useState(() => !cached?.data); const paintedRef = useRef(Boolean(cached?.data)); const secondaryGenerationRef = useRef(0); + const [machineShim, setMachineShim] = useState<{ installed?: boolean; healthy?: boolean } | null>(null); + const [machineBusy, setMachineBusy] = useState(false); + + useEffect(() => { + if (!connected) return; + const controller = new AbortController(); + void fetch(`${machineApiBase}/api/machine/shim`, { signal: controller.signal }) + .then(response => response.ok ? response.json() : null) + .then(value => { if (!controller.signal.aborted) setMachineShim(value); }) + .catch(() => { if (!controller.signal.aborted) setMachineShim(null); }); + return () => controller.abort(); + }, [connected, machineApiBase]); + + const runMachineShim = async (action: "install" | "repair" | "uninstall") => { + setMachineBusy(true); + try { + const response = await fetch(`${machineApiBase}/api/machine/shim`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + if (response.ok) { + const value = await response.json() as { shim?: { installed?: boolean; healthy?: boolean } }; + setMachineShim(value.shim ?? null); + } + } finally { setMachineBusy(false); } + }; useEffect(() => () => { secondaryGenerationRef.current += 1; @@ -304,6 +331,17 @@ export default function Startup({ apiBase }: { apiBase: string }) {
+ {connected && ( +
+ {t("connection.machine.title")} + {machineShim?.healthy ? t("connection.machine.shimHealthy") : t("connection.machine.shimNeedsAttention")} +
+ + {machineShim?.installed && } +
+
+ )} + {loadState.showSkeleton && !data ? ( ) : loadState.kind === "failed-cold" ? ( diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 019b93e6b0..fceb45fe04 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -1444,7 +1444,7 @@ export default function Storage({ apiBase }: { apiBase: string }) { ) : ( <> {reportState.showError &&
{t("storage.error")}
} - {empty ? : data && data.total.fileCount > 0 && } + {empty ? : data && data.total.fileCount > 0 && } )} diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 18cf77b4f8..769febe5b0 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -739,42 +739,47 @@ function UsageWorkspaceBody({ /** Held usage payloads so provider/surface tab switches skip a cold ~5s refetch. */ const usageMemoryCache = new Map(); -function usageCacheKey(apiBase: string, range: Range, surface: UsageSurface): string { - return `ocx.usage.v1:${apiBase}:${range}:${surface}`; +type UsageScope = "machine" | "hub"; + +function usageCacheKey(apiBase: string, range: Range, surface: UsageSurface, connected: boolean, scope: UsageScope, apiKeyId?: string): string { + return `ocx.usage.v2:${apiBase}:${connected ? "connected" : "standalone"}:${scope}:${apiKeyId ?? ""}:${range}:${surface}`; } -function readHeldUsage(apiBase: string, range: Range, surface: UsageSurface): UsageResponse | null { - const key = usageCacheKey(apiBase, range, surface); +function readHeldUsage(apiBase: string, range: Range, surface: UsageSurface, connected: boolean, scope: UsageScope, apiKeyId?: string): UsageResponse | null { + const key = usageCacheKey(apiBase, range, surface, connected, scope, apiKeyId); return usageMemoryCache.get(key) ?? readSessionListCache(key); } -function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageResponse) { - const key = usageCacheKey(apiBase, range, surface); +function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, connected: boolean, scope: UsageScope, apiKeyId: string | undefined, value: UsageResponse) { + const key = usageCacheKey(apiBase, range, surface, connected, scope, apiKeyId); usageMemoryCache.set(key, value); writeSessionListCache(key, value); } -export default function Usage({ apiBase }: { apiBase: string }) { +export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBase: string; connected?: boolean; apiKeyId?: string }) { const { t, locale } = useI18n(); const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); + const [scope, setScope] = useState("machine"); const [modelQuery, setModelQuery] = useState(""); const loadUsage = useCallback(async (signal: AbortSignal): Promise => { - const response = await fetch(`${apiBase}/api/usage?range=${range}&surface=${surface}`, { signal }); + const query = new URLSearchParams({ range, surface }); + if (connected && scope === "machine" && apiKeyId) query.set("apiKeyId", apiKeyId); + const response = await fetch(`${apiBase}/api/usage?${query}`, { signal }); if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); const next = await response.json() as UsageResponse; - writeHeldUsage(apiBase, range, surface, next); + writeHeldUsage(apiBase, range, surface, connected, scope, apiKeyId, next); return next; - }, [apiBase, range, surface]); + }, [apiBase, apiKeyId, connected, range, scope, surface]); - const resourceKey = usageCacheKey(apiBase, range, surface); - const cached = readHeldUsage(apiBase, range, surface); + const resourceKey = usageCacheKey(apiBase, range, surface, connected, scope, apiKeyId); + const cached = readHeldUsage(apiBase, range, surface, connected, scope, apiKeyId); // Range and surface identify different reports, so the key changes with both. That prevents // a force-loading dependency revalidation from ever showing a previous report as this one. const resource = useDataSurface( resourceKey, - [apiBase, range, surface], + [apiBase, apiKeyId, connected, range, scope, surface], loadUsage, { isEmpty: () => false, initialData: cached ?? undefined }, ); @@ -808,19 +813,28 @@ export default function Usage({ apiBase }: { apiBase: string }) {

{t("usage.subtitle")}

+
+ {t(connected ? "usage.source.connected" : "usage.source.local")} + {connected && ( +
+ + +
+ )} +
{state.showSkeleton && !data ? ( ) : state.kind === "failed-cold" ? ( - {state.error instanceof Error ? `${t("usage.loadError")} ${state.error.message}` : t("usage.loadError")}{" "} + {connected ? t("usage.hubOffline") : state.error instanceof Error ? `${t("usage.loadError")} ${state.error.message}` : t("usage.loadError")}{" "} ) : ( <> - {state.showError && {t("usage.loadError")}} + {state.showError && {t(connected ? "usage.hubOffline" : "usage.loadError")}} {data?.historyTruncated && ( // Naming the loaded window is the point: without it, `30d` and "Available history" // look identical on a busy installation even though both may cover far less than diff --git a/gui/src/stop-proxy.ts b/gui/src/stop-proxy.ts index ee98d3735f..0798f9b8f0 100644 --- a/gui/src/stop-proxy.ts +++ b/gui/src/stop-proxy.ts @@ -15,6 +15,7 @@ export interface ProxyStopOptions { fetchFn?: typeof fetch; timeoutMs?: number; formatFailure?: (status: number) => string; + mode?: "standalone" | "client"; } function failureMessage( @@ -46,11 +47,14 @@ export async function requestProxyStop( fetchFn = fetch, timeoutMs = DEFAULT_STOP_TIMEOUT_MS, formatFailure = status => `Failed to stop proxy (HTTP ${status}).`, + mode = "standalone", } = options; let response: Response; try { - response = await fetchFn(`${apiBase}/api/stop`, { + const path = mode === "client" ? "/api/machine/disconnect" : "/api/stop"; + response = await fetchFn(`${apiBase}${path}`, { method: "POST", + ...(mode === "client" ? { headers: { "Content-Type": "application/json" }, body: "{}" } : {}), signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa120669c8..aa9e3bb45c 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -200,3 +200,20 @@ min-height: auto; } } +.usage-source-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 14px; + color: var(--text-secondary); +} + +.usage-scope-control { + display: inline-flex; + gap: 6px; +} + +@media (max-width: 640px) { + .usage-source-row { align-items: flex-start; flex-direction: column; } +} diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index 08ff8d6afe..4623867698 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { + configureApiTargets, installApiAuthFetch, resetApiAuthFetchForTests, setRebootstrapTimeoutForTests, setResolutionWatchdogForTests, } from "../src/api"; +import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets"; const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; @@ -76,6 +78,44 @@ const MINTED = () => { Object.defineProperty(response, "url", { configurable: true, value: "http://localhost/opencodex-session" }); return response; }; + +test("a shared-target bootstrap watchdog does not block or clear the machine target", async () => { + for (const [name, content] of [ + ["opencodex-session-token", "ocx_session_machine"], + ["opencodex-session-csrf", "machine-csrf"], + ["opencodex-session-origin", "http://localhost"], + ["opencodex-session-server-origin", "http://localhost"], + ]) { + const meta = document.createElement("meta"); + meta.setAttribute("name", name); + meta.setAttribute("content", content); + document.head.append(meta); + } + const direct: MachineStatusV1 = { + mode: "client", connected: true, machineBase: "http://localhost", + sharedBase: "https://hub.example.test", sharedServerOrigin: "https://hub.example.test", + managementTransport: "direct", apiKeyId: "client-key-a", protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", hubReachability: "unknown", + }; + configureApiTargets(targetsFromMachineStatus("", direct)); + setRebootstrapTimeoutForTests(30); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + if (url.origin === "https://hub.example.test" && url.pathname === "/opencodex-session") { + return hangUntilAborted(init?.signal); + } + if (url.origin === "https://hub.example.test") return new Response("unauthorized", { status: 401 }); + const token = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get("x-opencodex-api-key"); + return new Response("{}", { status: token === "ocx_session_machine" ? 200 : 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + const shared = fetch("https://hub.example.test/api/config"); + const machine = await fetch("/api/machine/status"); + expect(machine.status).toBe(200); + expect((await shared).status).toBe(401); + expect(promptCalls).toBe(0); +}); test("hung bootstrap fails the wave within the deadline and a later wave re-bootstraps to success", async () => { setRebootstrapTimeoutForTests(50); let bootstrapCalls = 0; diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 834ecfa922..6483fbcaf5 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import { configureApiTargets, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; +import { targetsFromMachineStatus, type MachineStatusV1 } from "../src/api-targets"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; @@ -28,7 +29,9 @@ beforeEach(() => { Object.defineProperty(testWindow, "prompt", { configurable: true, writable: true, value: () => null }); } resetApiAuthFetchForTests(async () => { - return window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null; + return typeof window.prompt === "function" + ? window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null + : null; }); sessionStorage.clear(); }); @@ -465,6 +468,13 @@ test("a session minted for another origin is rejected and the prompt fallback st test("a renewed two-origin session attaches only to its bound server and carries browser origin plus CSRF", async () => { injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost"); + const status: MachineStatusV1 = { + mode: "client", connected: true, machineBase: "http://localhost", + sharedBase: "https://hub.example.test", sharedServerOrigin: "https://hub.example.test", + managementTransport: "direct", apiKeyId: "client-key-a", protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", hubReachability: "unknown", + }; + configureApiTargets(targetsFromMachineStatus("", status)); const seen = new Map(); let localApiCalls = 0; const record = (origin: string, headers: Headers) => { @@ -475,14 +485,14 @@ test("a renewed two-origin session attaches only to its bound server and carries const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); - if (url.pathname === "/opencodex-session") { + if (url.origin === "https://hub.example.test" && url.pathname === "/opencodex-session") { return htmlResponseAt( sessionDocumentHtml("ocx_session_remote", "remote-csrf", "http://localhost", "https://hub.example.test"), "https://hub.example.test/opencodex-session", ); } record(url.origin, headers); - if (url.origin === "http://localhost") { + if (url.origin === "https://hub.example.test") { localApiCalls += 1; return new Response("{}", { status: localApiCalls === 1 ? 401 : 200 }); } @@ -490,11 +500,11 @@ test("a renewed two-origin session attaches only to its bound server and carries }) as typeof fetch; await installMockAuthFetch(mockFetch); - expect((await fetch("/api/config")).status).toBe(200); expect((await fetch("https://hub.example.test/api/config", { method: "POST" })).status).toBe(200); + expect((await fetch("/api/machine/status")).status).toBe(200); expect((await fetch("https://evil.example.test/api/config")).status).toBe(200); - const hubHeaders = seen.get("https://hub.example.test")?.[0]; + const hubHeaders = seen.get("https://hub.example.test")?.at(-1); expect(hubHeaders?.get("X-OpenCodex-API-Key")).toBe("ocx_session_remote"); expect(hubHeaders?.get("X-OpenCodex-GUI-Origin")).toBe("http://localhost"); expect(hubHeaders?.get("X-OpenCodex-CSRF-Token")).toBe("remote-csrf"); @@ -503,6 +513,43 @@ test("a renewed two-origin session attaches only to its bound server and carries expect(evilHeaders?.get("X-OpenCodex-GUI-Origin")).toBeNull(); }); +test("relay requests carry independent shared and machine sessions without cross-target leakage", async () => { + injectSessionMeta("ocx_session_machine", "machine-csrf", "http://localhost"); + const seen = new Map(); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + seen.set(url.pathname, new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); + return new Response("{}", { status: 200 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + const relayStatus: MachineStatusV1 = { + mode: "client", connected: true, machineBase: "http://localhost", + sharedBase: "http://localhost/api/machine/hub-relay", sharedServerOrigin: "https://hub.example.test", + managementTransport: "relay", apiKeyId: "client-key-a", protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", hubReachability: "unknown", + }; + configureApiTargets(targetsFromMachineStatus("", relayStatus)); + expect(installApiSessionFromHtml("shared", sessionDocumentHtml( + "ocx_session_hub", "hub-csrf", "http://localhost", "https://hub.example.test", + ))).toBe(true); + + await fetch("/api/machine/status"); + await fetch("/api/machine/hub-relay/api/config", { method: "POST" }); + await fetch("https://evil.example/api/config"); + + const machine = seen.get("/api/machine/status")!; + expect(machine.get("x-opencodex-api-key")).toBe("ocx_session_machine"); + expect(machine.get("x-opencodex-machine-session")).toBeNull(); + const relay = seen.get("/api/machine/hub-relay/api/config")!; + expect(relay.get("x-opencodex-api-key")).toBe("ocx_session_hub"); + expect(relay.get("x-opencodex-csrf-token")).toBe("hub-csrf"); + expect(relay.get("x-opencodex-machine-session")).toBe("ocx_session_machine"); + expect(relay.get("x-opencodex-machine-csrf-token")).toBe("machine-csrf"); + const unknown = seen.get("/api/config")!; + expect(unknown.get("x-opencodex-api-key")).toBeNull(); + expect(unknown.get("x-opencodex-machine-session")).toBeNull(); +}); + test("a mismatched bootstrap response/server origin clears every in-memory session field", async () => { injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost"); const seenKeys: Array = []; diff --git a/gui/tests/api-targets.test.ts b/gui/tests/api-targets.test.ts new file mode 100644 index 0000000000..3aacf344b8 --- /dev/null +++ b/gui/tests/api-targets.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { + apiBaseForPlane, + discoverApiTargets, + relayUrlForPath, + standaloneApiTargets, + targetsFromMachineStatus, + type MachineStatusV1, +} from "../src/api-targets"; + +let win: Window; +let previousWindow: unknown; +let previousDocument: unknown; +let previousFetch: typeof fetch; + +/** + * Stand in for the runtime-role meta tag the server injects into the served document. + * `null` means the server said nothing, which every reader must treat as standalone. + */ +function setRuntimeRole(role: string | null): void { + const existing = win.document.querySelector('meta[name="opencodex-runtime-role"]'); + existing?.remove(); + if (role === null) return; + const meta = win.document.createElement("meta"); + meta.setAttribute("name", "opencodex-runtime-role"); + meta.setAttribute("content", role); + win.document.head.append(meta); +} + +const status = (transport: "direct" | "relay"): MachineStatusV1 => ({ + mode: "client", + connected: true, + machineBase: "http://localhost", + sharedBase: transport === "direct" ? "https://hub.example.test" : "http://localhost/api/machine/hub-relay", + sharedServerOrigin: "https://hub.example.test", + managementTransport: transport, + apiKeyId: "client-key-a", + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + hubReachability: "unknown", +}); + +beforeEach(() => { + previousWindow = Reflect.get(globalThis, "window"); + previousDocument = Reflect.get(globalThis, "document"); + previousFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(globalThis, "window", { configurable: true, value: win }); + Object.defineProperty(globalThis, "document", { configurable: true, value: win.document }); + // Most rows here exercise the connected path; the standalone rows set their own role. + setRuntimeRole("client"); +}); + +afterEach(() => { + globalThis.fetch = previousFetch; + Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, "document", { configurable: true, value: previousDocument }); + win.close(); +}); + +describe("two-plane API targets", () => { + test("404 selects the unchanged standalone same-origin target", async () => { + globalThis.fetch = (async () => new Response(null, { status: 404 })) as typeof fetch; + const targets = await discoverApiTargets(""); + expect(targets).toEqual(standaloneApiTargets("")); + expect(apiBaseForPlane("machine", targets)).toBe(""); + expect(apiBaseForPlane("shared", targets)).toBe(""); + }); + + test("constructs exact direct and fixed relay shared bases", () => { + const direct = targetsFromMachineStatus("", status("direct")); + expect(direct.shared).toMatchObject({ baseUrl: "https://hub.example.test", serverOrigin: "https://hub.example.test", transport: "direct" }); + const relay = targetsFromMachineStatus("", status("relay")); + expect(relay.machine.baseUrl).toBe(""); + expect(relay.shared).toMatchObject({ baseUrl: "/api/machine/hub-relay", serverOrigin: "https://hub.example.test", transport: "relay" }); + expect(relayUrlForPath(relay.shared, "/api/usage?range=all")).toBe("/api/machine/hub-relay/api/usage?range=all"); + expect(() => relayUrlForPath(relay.shared, "/api/%2e%2e/config")).toThrow(); + expect(() => relayUrlForPath(relay.shared, "//evil.example/api/config")).toThrow(); + }); + + test("a machine-status network failure is not treated as standalone", async () => { + setRuntimeRole("client"); + globalThis.fetch = (async () => { throw new TypeError("offline"); }) as typeof fetch; + await expect(discoverApiTargets("")).rejects.toThrow("local machine plane unavailable"); + }); + + test("standalone discovers nothing and sends no request", async () => { + // The whole point of the runtime-role meta tag: a user who never enabled remote hub + // must not have their browser probe a remote-hub endpoint. Discovery previously ran + // unconditionally and inferred standalone from the resulting 404 — a request that + // announced the feature's existence on every dashboard load. + let calls = 0; + globalThis.fetch = (async () => { calls += 1; return new Response(null, { status: 404 }); }) as typeof fetch; + + for (const role of [null, "standalone", "hub"] as const) { + calls = 0; + setRuntimeRole(role); + const targets = await discoverApiTargets(""); + expect(targets.connected).toBe(false); + expect(targets).toEqual(standaloneApiTargets("")); + expect(calls).toBe(0); + } + }); + + test("a connected runtime still discovers", async () => { + // The tag narrows who asks; it does not remove discovery for the role that needs it. + setRuntimeRole("client"); + let calls = 0; + globalThis.fetch = (async () => { calls += 1; return new Response(null, { status: 404 }); }) as typeof fetch; + await discoverApiTargets(""); + expect(calls).toBe(1); + }); +}); diff --git a/gui/tests/apikeys-layout.test.ts b/gui/tests/apikeys-layout.test.ts index a26e6c04f8..9291a846be 100644 --- a/gui/tests/apikeys-layout.test.ts +++ b/gui/tests/apikeys-layout.test.ts @@ -25,7 +25,7 @@ test("ApiKeys uses workspace shell (no classic layout toggle)", async () => { // ApiKeys is no longer rendered by App directly: WP5 made it one panel of // the Integrations tab strip, which is what passes `active` so a hidden // panel stops polling while its drafts stay mounted. - expect(app).toContain(""); + expect(app).toContain(''); expect(app).not.toContain(""); diff --git a/gui/tests/app-sidebar-actions.test.ts b/gui/tests/app-sidebar-actions.test.ts index d8f545f504..15648f6595 100644 --- a/gui/tests/app-sidebar-actions.test.ts +++ b/gui/tests/app-sidebar-actions.test.ts @@ -50,7 +50,7 @@ test("the restart action comes from the shared hook, not an inline duplicate", ( // The models page reuses the same controller; a second inline implementation // would drift on the four-branch message mapping. The hook now also takes an // options object, so match the call rather than one exact argument list. - expect(src).toContain("useCodexRestart(API_BASE"); + expect(src).toContain("useCodexRestart(sharedBase"); expect(src).not.toContain("requestCodexRestart("); }); @@ -117,4 +117,3 @@ test("every restart string exists in the English source with its slots intact", expect(en["dash.codexRestartPartial"]).toContain("{count}"); expect(en["dash.codexRestartFailed"]).toContain("{status}"); }); - diff --git a/gui/tests/app-stop.test.ts b/gui/tests/app-stop.test.ts index 24046b4556..c0711fa0fe 100644 --- a/gui/tests/app-stop.test.ts +++ b/gui/tests/app-stop.test.ts @@ -9,6 +9,20 @@ function response(body: unknown, status = 200): Response { } describe("App proxy stop", () => { + test("routes standalone stop and connected disconnect to different machine mutations", async () => { + const seen: Array<{ url: string; method: string; body: unknown }> = []; + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), method: String(init?.method), body: init?.body }); + return response({ success: true }, init?.body ? 202 : 200); + }) as typeof fetch; + expect((await requestProxyStop("http://machine", { fetchFn })).accepted).toBe(true); + expect((await requestProxyStop("http://machine", { fetchFn, mode: "client" })).accepted).toBe(true); + expect(seen).toEqual([ + { url: "http://machine/api/stop", method: "POST", body: undefined }, + { url: "http://machine/api/machine/disconnect", method: "POST", body: "{}" }, + ]); + }); + test("releases the pending UI and exposes a non-2xx server message", async () => { const outcome = await requestProxyStop("", { fetchFn: (async () => response({ @@ -61,7 +75,8 @@ describe("App proxy stop", () => { expect(brandIdx).toBeGreaterThan(handleStopIdx); const handler = app.slice(handleStopIdx, brandIdx); - expect(handler).toContain("await requestProxyStop(API_BASE"); + expect(handler).toContain("await requestProxyStop(machineBase"); + expect(handler).toContain('mode: targets.connected ? "client" : "standalone"'); expect(handler).toContain("if (!outcome.accepted)"); expect(handler).toContain("setStopping(false)"); expect(handler).toContain("alert(outcome.message)"); diff --git a/gui/tests/claude-toggle-race.test.tsx b/gui/tests/claude-toggle-race.test.tsx index b66f5fd94a..91bd6d824f 100644 --- a/gui/tests/claude-toggle-race.test.tsx +++ b/gui/tests/claude-toggle-race.test.tsx @@ -88,6 +88,7 @@ beforeEach(() => { const url = String(input instanceof Request ? input.url : input); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + if (url.includes("/api/machine/status")) return jsonResponse({}, 404); if (url.includes("/api/claude-code") && method === "PUT") { const body = JSON.parse(String(init?.body ?? "{}")) as { enabled?: boolean }; putBodies.push(body); @@ -126,6 +127,14 @@ afterEach(async () => { releasePut = null; putGate = null; testWindow.close(); + // Clear the auth-fetch install latch along with the window it was installed against. + // + // `installApiAuthFetch` installs once per module instance. Leaving the latch set after + // this window closes makes a LATER test's own install a silent no-op, so its requests go + // out unwrapped and it fails only when run after this file. Restoring the globals is not + // enough; the latch lives in the module. + const { resetApiAuthFetchForTests } = await import("../src/api"); + resetApiAuthFetchForTests(); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); } diff --git a/gui/tests/claudecode-layout.test.ts b/gui/tests/claudecode-layout.test.ts index f24113058f..7dc26b08f5 100644 --- a/gui/tests/claudecode-layout.test.ts +++ b/gui/tests/claudecode-layout.test.ts @@ -17,7 +17,7 @@ test("ClaudeCode renders the denser workspace rail layout", async () => { // Claude is now a panel of the Integrations tab strip rather than its own // top-level page, so App renders the shell and the shell renders Claude. - expect(app).toContain(""); + expect(app).toContain(''); const integrations = await Bun.file(new URL("../src/pages/Integrations.tsx", import.meta.url)).text(); expect(integrations).toContain(""); // Title/subtitle sit above the Code/Desktop strip (not inside each panel). diff --git a/gui/tests/codex-stale-banner.test.ts b/gui/tests/codex-stale-banner.test.ts index 04e9bbfb39..ba2c3f6506 100644 --- a/gui/tests/codex-stale-banner.test.ts +++ b/gui/tests/codex-stale-banner.test.ts @@ -153,7 +153,7 @@ describe("cross-surface invalidation", () => { test("the epoch is the only cross-surface coupling, not a shared controller", () => { // Two controllers is deliberate: the backend is single-flight, so what was // missing is invalidation rather than mutual exclusion. - expect(APP_SRC).toContain("useCodexRestart(API_BASE, {"); + expect(APP_SRC).toContain("useCodexRestart(sharedBase, {"); expect(MODELS).toContain("useCodexRestart(apiBase, {"); }); }); diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts new file mode 100644 index 0000000000..ae68a1d1f7 --- /dev/null +++ b/gui/tests/connect-pairing.test.ts @@ -0,0 +1,163 @@ +import { afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; + +test("App mounts the relay pairing form and installs only the returned shared session", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "localStorage", "fetch", "confirm", "alert", "IS_REACT_ACT_ENVIRONMENT", "__APP_VERSION__"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); + const win = new Window({ url: "http://localhost/#usage" }); + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, + document: { configurable: true, value: win.document }, + navigator: { configurable: true, value: win.navigator }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + localStorage: { configurable: true, value: win.localStorage }, + confirm: { configurable: true, value: () => true }, + alert: { configurable: true, value: () => {} }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + __APP_VERSION__: { configurable: true, value: "0.0.0-test" }, + }); + for (const [name, content] of [ + ["opencodex-session-token", "ocx_session_machine"], + ["opencodex-session-csrf", "machine-csrf"], + ["opencodex-session-origin", "http://localhost"], + ["opencodex-session-server-origin", "http://localhost"], + // The server states the role in the served document. Without it this reads as + // standalone, discovery never runs, and the relay pairing form never mounts — which + // is exactly the behavior a plain install should get. + ["opencodex-runtime-role", "client"], + ]) { + const meta = document.createElement("meta"); + meta.name = name; + meta.content = content; + document.head.append(meta); + } + + let pairingRequest: { method: string; body: string; headers: Headers } | null = null; + const sessionHtml = [ + '', + '', + '', + '', + ].join(""); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (url.pathname === "/api/machine/status") return Response.json({ + mode: "client", connected: true, machineBase: "http://localhost", + sharedBase: "http://localhost/api/machine/hub-relay", + sharedServerOrigin: "https://hub.example.test", managementTransport: "relay", + apiKeyId: "client-key-a", protocolVersion: 1, connectedAt: "2026-08-28T00:00:00.000Z", + hubReachability: "unknown", + }); + if (url.pathname === "/api/machine/hub-relay/opencodex-session" && init?.method === "POST") { + pairingRequest = { method: init.method, body: String(init.body), headers }; + return new Response(sessionHtml, { headers: { "Content-Type": "text/html" } }); + } + if (url.pathname === "/healthz") return Response.json({ version: "0.0.0-test" }); + if (url.pathname.endsWith("/api/usage")) return Response.json({ + range: "30d", surface: "all", since: null, generatedAt: Date.now(), + summary: { requests: 0, attemptCount: 0, measuredRequests: 0, reportedRequests: 0, unreportedRequests: 0, unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, coverageRatio: 0, estimatedCostUsd: 0, pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 0 }, + days: [], models: [], providers: [], accounts: [], historyTruncated: false, + }); + return Response.json({}); + }) as typeof fetch; + Object.defineProperties(globalThis, { + fetch: { configurable: true, value: mockFetch }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: mockFetch }); + + const container = document.createElement("div"); + document.body.append(container); + const { LanguageProvider } = await import("../src/i18n/provider"); + // Bind the auth-fetch wrapper to THIS window before App mounts. + // + // App calls installApiAuthFetch() at module scope, so it runs on first import only. A + // later test importing App gets the cached module and no install, leaving the wrapper + // bound to whichever window imported it first. The relayed pairing request then goes out + // unwrapped — no machine-session headers, which is exactly what this test asserts. + // Standalone the ordering happens to work; in the full suite it does not. Re-binding here + // makes the test independent of import order rather than of any product behavior. + const { resetApiAuthFetchForTests, installApiAuthFetch, configureApiTargets } = await import("../src/api"); + const { standaloneApiTargets } = await import("../src/api-targets"); + resetApiAuthFetchForTests(); + configureApiTargets(standaloneApiTargets("")); + installApiAuthFetch(); + const { default: App } = await import("../src/App"); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: win.fetch }); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + try { + await act(async () => { root.render(createElement(LanguageProvider, null, createElement(App))); }); + const deadline = Date.now() + 1_000; + while (!container.querySelector("#connect-pairing-code")) { + if (Date.now() >= deadline) throw new Error("pairing form did not mount from App"); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 10)); }); + } + const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); + await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); + const form = input.closest("form")!; + await act(async () => { form.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); }); + const successDeadline = Date.now() + 1_000; + while (container.querySelector("#connect-pairing-code")) { + if (Date.now() >= successDeadline) throw new Error("pairing form did not hide after success"); + await act(async () => { await Promise.resolve(); }); + } + expect(pairingRequest?.method).toBe("POST"); + expect(pairingRequest?.body).toBe(JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` })); + expect(pairingRequest?.headers.get("x-opencodex-machine-session")).toBe("ocx_session_machine"); + expect(pairingRequest?.headers.get("x-opencodex-api-key")).toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + win.close(); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); + } +}); + +test("a refused pairing renders an accessible error without clearing the pasted code", async () => { + const keys = ["window", "document", "navigator", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); + const win = new Window({ url: "http://localhost/" }); + const mockFetch = (async () => new Response("refused", { status: 403 })) as typeof fetch; + Object.defineProperties(globalThis, { + window: { configurable: true, value: win }, + document: { configurable: true, value: win.document }, + navigator: { configurable: true, value: win.navigator }, + sessionStorage: { configurable: true, value: win.sessionStorage }, + fetch: { configurable: true, value: mockFetch }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + Object.defineProperty(win, "fetch", { configurable: true, value: mockFetch }); + const container = document.createElement("div"); + document.body.append(container); + const { LanguageProvider } = await import("../src/i18n/provider"); + const { ConnectPairingForm } = await import("../src/connect-pairing"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + const code = `ocx_pair_${"b".repeat(43)}`; + try { + await act(async () => { + root.render(createElement(LanguageProvider, null, createElement(ConnectPairingForm, { + target: { id: "shared", baseUrl: "https://hub.example.test", serverOrigin: "https://hub.example.test", bootstrapPath: "https://hub.example.test/opencodex-session", transport: "direct" }, + onConnected: () => { throw new Error("unexpected success"); }, + }))); + }); + const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, code); + await act(async () => { input.dispatchEvent(new win.Event("input", { bubbles: true })); }); + await act(async () => { input.closest("form")!.dispatchEvent(new win.Event("submit", { bubbles: true, cancelable: true })); }); + const deadline = Date.now() + 1_000; + while (!container.querySelector('[role="alert"]')) { + if (Date.now() >= deadline) throw new Error("pairing error did not render"); + await act(async () => { await Promise.resolve(); }); + } + expect(input.value).toBe(code); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + win.close(); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); + } +}); diff --git a/gui/tests/integrations-routing.test.ts b/gui/tests/integrations-routing.test.ts index f96dff5b73..385148c042 100644 --- a/gui/tests/integrations-routing.test.ts +++ b/gui/tests/integrations-routing.test.ts @@ -119,6 +119,28 @@ describe("the collapse disturbs no neighbouring route", () => { }); }); +describe("two-plane integration call routing", () => { + test("existing integration descendants stay on the shared base and only machine controls use machineApiBase", async () => { + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const integrations = await Bun.file(new URL("../src/pages/Integrations.tsx", import.meta.url)).text(); + const startup = await Bun.file(new URL("../src/pages/Startup.tsx", import.meta.url)).text(); + expect(app).toContain(''); + expect(app).toContain(''); + for (const component of ["ApiKeys", "Grok", "Claude", "IntegrationsOverview", "FileIntegrationPage"]) { + expect(integrations).toContain(`${component}`); + } + expect(integrations).toContain(" { let win: Window; let previous: Record; diff --git a/gui/tests/sidebar-codex-set.test.ts b/gui/tests/sidebar-codex-set.test.ts index 9c726942d2..d0de10c569 100644 --- a/gui/tests/sidebar-codex-set.test.ts +++ b/gui/tests/sidebar-codex-set.test.ts @@ -26,7 +26,7 @@ test("Codex Set is always present in the sidebar, never filtered by view mode", // It stays in the nav table and remains routable for deep links. expect(src).toContain('{ id: "codex-set", tkey: "nav.codexSet", Icon: IconKey }'); - expect(src).toContain('{page === "codex-set" && }'); + expect(src).toContain('{page === "codex-set" && }'); }); test("the shipped #codex-auth bookmark still resolves", async () => { diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index d77388b153..922bf9baf5 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -23,13 +23,23 @@ test("Usage renders every section in one scrollable column with a sticky strip", expect(page).toContain(""); + expect(app).toContain(''); expect(css).toContain("styles-usage-workspace.css"); // The strip has to stay reachable while reading down the page. expect(css).toContain(".section-tabs"); expect(css).toContain("position: sticky"); }); +test("connected Usage defaults to the exact machine key and can toggle hub-wide without local fallback", async () => { + const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); + expect(src).toContain('useState("machine")'); + expect(src).toContain('query.set("apiKeyId", apiKeyId)'); + expect(src).toContain('setScope("hub")'); + expect(src).toContain('connected ? "connected" : "standalone"'); + expect(src).toContain('t("usage.hubOffline")'); + expect(src).not.toContain("/api/machine/usage"); +}); + test("Usage workspace sections mount report panels in order", async () => { const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); @@ -61,7 +71,7 @@ test("Usage loading and empty states guard the workspace body", async () => { }); test("usage workspace i18n keys exist in every locale", async () => { - const locales = ["en", "de", "fr", "ja", "ko", "ru", "zh", "zh-TW"] as const; + const locales = ["en", "de", "fr", "ja", "ko", "ru", "tr", "zh", "zh-TW"] as const; for (const locale of locales) { const dict = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); expect(dict).toContain('"usage.workspace.sections":'); @@ -70,6 +80,10 @@ test("usage workspace i18n keys exist in every locale", async () => { expect(dict).toContain('"usage.historyTruncated":'); expect(dict).toContain('"usage.historyTruncatedWindow":'); expect(dict).toContain('"api.attribution.totalRequestsAvailable":'); + expect(dict).toContain('"usage.source.connected":'); + expect(dict).toContain('"usage.scope.machine":'); + expect(dict).toContain('"usage.scope.hub":'); + expect(dict).toContain('"usage.hubOffline":'); } }); diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 1182a9b2fb..09ed24bc1b 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -24,7 +24,7 @@ export const CONNECT_USAGE = `Usage: ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] - [--allow-insecure-http] [--no-sync] + [--no-sync] ocx connect status [--json] ocx connect revoke --admin-token-stdin [--json]`; @@ -127,7 +127,6 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { } const pairing = takeFlag(args, "--pairing-code-stdin"); const admin = takeFlag(args, "--admin-token-stdin"); - const allowInsecureHttp = takeFlag(args, "--allow-insecure-http"); const noSync = takeFlag(args, "--no-sync"); if (Number(pairing) + Number(admin) !== 1) { throw new CliUsageError("choose exactly one of --pairing-code-stdin or --admin-token-stdin", CONNECT_USAGE); @@ -141,7 +140,6 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { credential: { kind: pairing ? "pairing-grant" : "admin", value }, selectedClients: clients, managementTransport, - allowInsecureHttp, noSync, }, { fetchImpl: deps.fetchImpl }); console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b3ecc87daa..75275753ad 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -63,10 +63,6 @@ const commandRunners: Record = { const { readClientConnectionState } = await import("../client/state"); const clientState = readClientConnectionState(); await reconcileClientJournalBeforeLifecycle(clientState); - if (clientState.kind === "connected") { - console.error("Client mode does not start a local provider proxy in Remote Hub Phase 3; use 'ocx sync'."); - return 1; - } if (clientState.kind === "invalid" || clientState.kind === "mismatched") { console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); return 1; diff --git a/src/cli/index.ts b/src/cli/index.ts index 74e58af77f..9afe21ec02 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -59,7 +59,6 @@ import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/pr import { loadServiceTokenFromFile } from "../lib/service-secrets"; import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; -import { drainAndShutdown, isRecyclingForExit, startServer } from "../server"; import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env"; import { buildDesktop3pRegistry } from "../claude/desktop-3p"; import { startTokenGuardian } from "../oauth/token-guardian"; @@ -270,6 +269,16 @@ async function handleStart(options: { block?: boolean } = {}) { process.exit(1); } + const clientState = readClientConnectionState(); + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + throw new Error(`client startup refused: ${clientState.reason}`); + } + if (clientState.kind === "connected") { + const { startClientRuntime } = await import("../client/runtime"); + await startClientRuntime({ port: requestedPort, block: options.block }); + return; + } + // Interactive-only update prompt. Must run BEFORE we bind a port / write a // PID: choosing "Update now" installs globally and exits, so we never want a // live daemon holding resources while it overwrites its own binary. @@ -279,6 +288,7 @@ async function handleStart(options: { block?: boolean } = {}) { // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries // the same port only (never hop — that was the remaining PR #152 gap). let port = await chooseListenPort(requestedPort); + const { drainAndShutdown, isRecyclingForExit, startServer } = await import("../server"); // One private readiness gate for this startServer invocation, captured by the // listener's closure. handleStart owns it and transitions it after the // post-startup sync settles. A second startServer in the same process would diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f09356d4e3..f6e3d76f2b 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -88,7 +88,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." }, { name: "connect", - usage: "ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--allow-insecure-http] [--no-sync]", + usage: "ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--no-sync]", summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.", details: [ "Status: ocx connect status [--json]", diff --git a/src/client/connect.ts b/src/client/connect.ts index 2bdeea9558..4e915d0c87 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -54,7 +54,6 @@ export interface ConnectOptions { selectedClients: OcxConnectedClientId[]; managementTransport: "direct" | "relay"; noSync?: boolean; - allowInsecureHttp?: boolean; } export interface ClientConnectDeps { @@ -107,10 +106,17 @@ function validLocalCatalog(): string { return snapshot.body; } -function catalogMatchesEtag(body: string, etag: string | undefined): boolean { - if (!etag) return false; - const digest = createHash("sha256").update(body).digest("base64url"); - return etag === `"sha256-${digest}"` || etag === `W/"sha256-${digest}"`; +/** + * Is the on-disk catalog still the one this connection wrote? + * + * Recorded as our own hash rather than the hub's ETag: /v1/catalog emits no validator + * (Phase 1, D2), so there is no server-supplied tag to keep. This is an ownership check on + * local bytes, which never needed the hub's participation — the previous spelling only + * looked like a cache concern because it reused the ETag string. + */ +function catalogMatchesFingerprint(body: string, fingerprint: string | undefined): boolean { + if (!fingerprint) return false; + return createHash("sha256").update(body).digest("base64url") === fingerprint; } function routingTarget(serverUrl: string): CodexRoutingTarget { @@ -183,16 +189,13 @@ export async function connectClient( const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl }); if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`); managementUrl = managementUrl || ready.metadata.managementUrl; - if (options.managementTransport === "relay") { - throw new Error("relay management transport is not available before Remote Hub Phase 4"); - } if (options.credential.kind === "pairing-grant") { const session = await exchangeConnectPairingGrant( managementUrl, localGuiOrigin(), options.credential.value, - { allowInsecureHttp: options.allowInsecureHttp, fetchImpl: deps.fetchImpl }, + { fetchImpl: deps.fetchImpl }, ); cleanupCredential = { kind: "gui-session", value: session }; } else { @@ -205,9 +208,6 @@ export async function connectClient( tokenFingerprint = persisted.fingerprint; const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl }); - if (catalog.kind !== "fresh" || !catalog.etag) { - throw new Error("initial hub catalog did not include a fresh ETag"); - } atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); writtenCatalogFingerprint = sha256(catalog.body); @@ -244,9 +244,9 @@ export async function connectClient( tokenFingerprint: persisted.fingerprint, protocolVersion: 1, connectedAt: now, - catalogEtag: catalog.etag, + catalogFingerprint: createHash("sha256").update(catalog.body).digest("base64url"), // Durable so disconnect — a different process — can put back whatever was here - // before. `priorCatalog` above is only reachable by a connect that fails and rolls + // before. The in-memory `priorCatalog` only covers a connect that fails and rolls // back in the same run. priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "", catalogSyncedAt: now, @@ -305,22 +305,17 @@ export async function syncConnectedClient( let next = state.value; try { const downloaded = await downloadClientCatalog(state.value.serverUrl, token.token, { - etag: state.value.catalogEtag, fetchImpl: deps.fetchImpl, }); - if (downloaded.kind === "fresh") { - atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); - catalogWritten = true; - const now = (deps.now ?? (() => new Date()))().toISOString(); - next = { - ...state.value, - ...(downloaded.etag ? { catalogEtag: downloaded.etag } : {}), - catalogSyncedAt: now, - }; - commitClientConnection(next); - } else { - validLocalCatalog(); - } + atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body); + catalogWritten = true; + const now = (deps.now ?? (() => new Date()))().toISOString(); + next = { + ...state.value, + catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"), + catalogSyncedAt: now, + }; + commitClientConnection(next); } catch (error) { const transient = error instanceof HubClientError && (error.code === "unreachable" || (error.status !== undefined && error.status >= 500)); @@ -359,7 +354,7 @@ function restorePriorCatalog(connection: OcxClientConnectionConfig): "removed" | if (!existsSync(DEFAULT_CATALOG_PATH)) return "absent"; try { const body = validLocalCatalog(); - if (!catalogMatchesEtag(body, connection.catalogEtag)) return "changed"; + if (!catalogMatchesFingerprint(body, connection.catalogFingerprint)) return "changed"; if (connection.priorCatalog) { atomicWriteFile(DEFAULT_CATALOG_PATH, Buffer.from(connection.priorCatalog, "base64").toString("utf8")); return "restored"; diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts index b60126476f..9fcb94be6d 100644 --- a/src/client/hub-client.ts +++ b/src/client/hub-client.ts @@ -1,4 +1,24 @@ import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; + +/** + * A pairing grant may cross loopback or authenticated HTTPS, and nothing else. + * + * Mirrors the hub-side rule in src/server/gui-session.ts. Checking here too is not + * redundant: it keeps the client from spending a single-use code on a request the hub is + * certain to refuse. + */ +function isPairingTransportPermitted(origin: string): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.protocol === "https:") return true; + if (url.protocol !== "http:") return false; + const host = url.hostname.toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; +} import { checkRemoteProtocolCompatibility, parseRemoteReadyMetadata, @@ -169,12 +189,17 @@ export async function exchangeConnectPairingGrant( managementUrl: string, browserOrigin: string, grant: Uint8Array, - options: { allowInsecureHttp?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch } = {}, + options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}, ): Promise { const origin = normalizeHubOrigin(managementUrl); const browser = normalizeHubOrigin(browserOrigin); - if (new URL(origin).protocol !== "https:" && options.allowInsecureHttp !== true) { - throw new HubClientError("insecure_http_refused", "Pairing over HTTP requires --allow-insecure-http"); + // No opt-in. An earlier revision let `--allow-insecure-http` carry a grant over plaintext + // when the hub also opted in, on the theory that requiring both sides made it deliberate. + // Deliberateness is not the control that matters: the grant is readable by anything on the + // path and the session it mints is reusable. The hub refuses this exchange outright now, so + // sending it would only burn a single-use code against a certain rejection. + if (!isPairingTransportPermitted(origin)) { + throw new HubClientError("insecure_http_refused", "Pairing requires loopback or HTTPS; plaintext HTTP cannot carry a grant"); } const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/opencodex-session`, { method: "POST", @@ -277,16 +302,20 @@ export async function revokeClientKey( export async function downloadClientCatalog( serverUrl: string, admissionToken: string, - options: { etag?: string; timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch } = {}, -): Promise<{ kind: "fresh"; body: string; etag?: string } | { kind: "not-modified" }> { + options: { timeoutMs?: number; maxBytes?: number; fetchImpl?: typeof fetch } = {}, +): Promise<{ kind: "fresh"; body: string }> { const origin = normalizeHubOrigin(serverUrl); const headers = new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }); - if (options.etag) headers.set("If-None-Match", options.etag); + // Unconditional by contract: /v1/catalog emits no validator (Phase 1, D2) because its + // body varies by key identity, so there is nothing to revalidate against and a 304 could + // only come from a hub that is misconfigured or being impersonated. const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, { method: "GET", headers, }, options.timeoutMs); - if (response.status === 304) return { kind: "not-modified" }; + if (response.status === 304) { + throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304); + } if (!response.ok) { const code = response.status === 401 ? "catalog_unauthorized" : `catalog_http_${response.status}`; throw new HubClientError(code, `Hub catalog request failed (${response.status})`, response.status); @@ -296,6 +325,5 @@ export async function downloadClientCatalog( if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new HubClientError("catalog_invalid", "Hub catalog response was invalid", response.status); } - const etag = response.headers.get("etag")?.trim() || undefined; - return { kind: "fresh", body, ...(etag ? { etag } : {}) }; + return { kind: "fresh", body }; } diff --git a/src/client/hub-relay.ts b/src/client/hub-relay.ts new file mode 100644 index 0000000000..ae75862f8e --- /dev/null +++ b/src/client/hub-relay.ts @@ -0,0 +1,202 @@ +import { stripMachineAuthHeaders } from "./machine-auth"; + +export interface HubRelayTarget { + managementUrl: string; + browserOrigin: string; +} + +export const HUB_RELAY_REQUEST_BODY_MAX_BYTES = 4 * 1024 * 1024; +export const HUB_RELAY_RESPONSE_BODY_MAX_BYTES = 16 * 1024 * 1024; +export const HUB_RELAY_DEFAULT_TIMEOUT_MS = 15_000; +export const HUB_RELAY_HEADER_MAX_BYTES = 64 * 1024; + +const ALLOWED_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]); +const REQUEST_HEADERS = new Set([ + "accept", + "accept-language", + "cache-control", + "content-type", + "if-match", + "if-modified-since", + "if-none-match", + "if-unmodified-since", + "origin", + "x-opencodex-api-key", + "x-opencodex-csrf-token", + "x-opencodex-gui-origin", +]); +const RESPONSE_HEADERS = new Set([ + "cache-control", + "content-language", + "content-type", + "etag", + "expires", + "last-modified", + "pragma", + "retry-after", + "vary", +]); + +function relayError(status: number, error: string): Response { + return Response.json({ error }, { status }); +} + +function canonicalOrigin(value: string): string | null { + try { + const url = new URL(value); + if ((url.protocol !== "http:" && url.protocol !== "https:") + || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return null; + return url.origin; + } catch { + return null; + } +} + +function relayDestination(suffix: string, target: HubRelayTarget, method: string): URL | null { + const origin = canonicalOrigin(target.managementUrl); + const browserOrigin = canonicalOrigin(target.browserOrigin); + if (!origin || !browserOrigin || !ALLOWED_METHODS.has(method)) return null; + if (!suffix.startsWith("/") || suffix.startsWith("//") || suffix.includes("\\") || suffix.includes("#")) return null; + if (/%(?:2f|5c)/i.test(suffix) || /%(?:2e)(?:%2e|\.)?/i.test(suffix)) return null; + const rawPath = suffix.split("?", 1)[0]!; + for (const segment of rawPath.split("/")) { + let decoded: string; + try { decoded = decodeURIComponent(segment); } catch { return null; } + if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) return null; + } + if (rawPath === "/opencodex-session") { + if (suffix !== rawPath || (method !== "GET" && method !== "POST")) return null; + } else if (!rawPath.startsWith("/api/")) { + return null; + } + let destination: URL; + try { destination = new URL(suffix, `${origin}/`); } catch { return null; } + if (destination.origin !== origin || destination.username || destination.password || destination.hash) return null; + if (destination.pathname !== rawPath) return null; + return destination; +} + +async function boundedBody( + stream: ReadableStream | null, + declared: string | null, + limit: number, +): Promise | null> { + if (!stream) return null; + const contentLength = declared === null ? null : Number(declared); + if (contentLength !== null && (!Number.isSafeInteger(contentLength) || contentLength < 0 || contentLength > limit)) { + throw new RangeError("body_too_large"); + } + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + length += next.value.byteLength; + if (length > limit) throw new RangeError("body_too_large"); + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + // BodyInit requires an ArrayBuffer-backed view, not a SharedArrayBuffer-capable view. + const body: Uint8Array = new Uint8Array(new ArrayBuffer(length)); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function filteredHeaders(source: Headers, allowlist: Set): Headers { + const headers = new Headers(); + for (const [name, value] of source) { + if (allowlist.has(name.toLowerCase())) headers.append(name, value); + } + return headers; +} + +function headersWithinLimit(headers: Headers): boolean { + let bytes = 0; + for (const [name, value] of headers) { + bytes += name.length + value.length + 4; + if (bytes > HUB_RELAY_HEADER_MAX_BYTES) return false; + } + return true; +} + +export async function relayHubManagementRequest( + req: Request, + suffix: string, + target: HubRelayTarget, + deps: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + const method = req.method.toUpperCase(); + const destination = relayDestination(suffix, target, method); + if (!destination) return relayError(404, "hub relay path refused"); + + let body: Uint8Array | null; + try { + body = method === "GET" || method === "HEAD" + ? null + : await boundedBody(req.body, req.headers.get("content-length"), HUB_RELAY_REQUEST_BODY_MAX_BYTES); + } catch { + return relayError(413, "hub relay request body too large"); + } + + const stripped = stripMachineAuthHeaders(req.headers); + const headers = filteredHeaders(stripped, REQUEST_HEADERS); + if (!headersWithinLimit(headers)) return relayError(431, "hub relay request headers too large"); + const browserOrigin = canonicalOrigin(target.browserOrigin); + const mutation = method !== "GET" && method !== "HEAD"; + const suppliedOrigin = headers.get("origin"); + if (!browserOrigin || (mutation ? suppliedOrigin !== browserOrigin : suppliedOrigin !== null && suppliedOrigin !== browserOrigin)) { + return relayError(403, "hub relay browser origin refused"); + } + + const timeoutMs = typeof deps.timeoutMs === "number" && Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0 + ? Math.min(Math.floor(deps.timeoutMs), 120_000) + : HUB_RELAY_DEFAULT_TIMEOUT_MS; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = req.signal + ? AbortSignal.any([req.signal, timeoutSignal]) + : timeoutSignal; + let upstream: Response; + try { + upstream = await (deps.fetchImpl ?? fetch)(destination, { + method, + headers, + ...(body ? { body } : {}), + redirect: "manual", + signal, + }); + } catch { + return relayError(502, "hub relay unavailable"); + } + if (upstream.status >= 300 && upstream.status < 400) { + try { await upstream.body?.cancel(); } catch { /* best effort */ } + return relayError(502, "hub relay redirect refused"); + } + + let responseBody: Uint8Array | null; + const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS); + if (!headersWithinLimit(responseHeaders)) { + try { await upstream.body?.cancel(); } catch { /* best effort */ } + return relayError(502, "hub relay response headers too large"); + } + try { + responseBody = method === "HEAD" + ? null + : await boundedBody(upstream.body, upstream.headers.get("content-length"), HUB_RELAY_RESPONSE_BODY_MAX_BYTES); + } catch { + try { await upstream.body?.cancel(); } catch { /* best effort */ } + return relayError(502, "hub relay response body too large"); + } + return new Response(responseBody, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); +} diff --git a/src/client/machine-api.ts b/src/client/machine-api.ts new file mode 100644 index 0000000000..fe92a4a88c --- /dev/null +++ b/src/client/machine-api.ts @@ -0,0 +1,139 @@ +import { journalOwner } from "../codex/journal"; +import { diagnoseCodexShim, installCodexShim, uninstallCodexShim } from "../codex/shim"; +import { readManagementJsonBody } from "../server/management/body"; +import type { OcxClientConnectionConfig } from "../types"; +import { disconnectClient, syncConnectedClient } from "./connect"; + +export type HubReachability = "unknown" | "online" | "offline" | "unauthorized"; + +export interface MachineStatusV1 { + mode: "client"; + connected: true; + machineBase: string; + sharedBase: string; + sharedServerOrigin: string; + managementTransport: "direct" | "relay"; + apiKeyId: string; + protocolVersion: 1; + connectedAt: string; + catalogSyncedAt?: string; + hubReachability: HubReachability; +} + +export interface MachineApiDeps { + sync: typeof syncConnectedClient; + disconnect: typeof disconnectClient; + scheduleStandaloneRecycle: () => void; + hubReachability?: () => HubReachability; + setHubReachability?: (value: HubReachability) => void; +} + +const defaultDeps: MachineApiDeps = { + sync: syncConnectedClient, + disconnect: disconnectClient, + scheduleStandaloneRecycle: () => {}, +}; + +function strictObject(value: unknown, allowed: readonly string[]): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + return Object.keys(record).every(key => allowed.includes(key)) ? record : null; +} + +async function jsonBody(req: Request): Promise { + try { + return await readManagementJsonBody(req); + } catch { + return Response.json({ error: "invalid JSON body" }, { status: 400 }); + } +} + +function statusPayload(req: Request, state: OcxClientConnectionConfig, deps: MachineApiDeps): MachineStatusV1 { + const machineBase = new URL(req.url).origin; + return { + mode: "client", + connected: true, + machineBase, + sharedBase: state.managementTransport === "relay" + ? `${machineBase}/api/machine/hub-relay` + : state.managementUrl, + sharedServerOrigin: state.managementUrl, + managementTransport: state.managementTransport, + apiKeyId: state.apiKeyId, + protocolVersion: state.protocolVersion, + connectedAt: state.connectedAt, + ...(state.catalogSyncedAt ? { catalogSyncedAt: state.catalogSyncedAt } : {}), + hubReachability: deps.hubReachability?.() ?? "unknown", + }; +} + +export async function handleMachineApi( + req: Request, + url: URL, + state: OcxClientConnectionConfig, + injected: MachineApiDeps = defaultDeps, +): Promise { + const deps = { ...defaultDeps, ...injected }; + if (url.pathname === "/api/machine/status" && req.method === "GET") { + return Response.json(statusPayload(req, state, deps), { headers: { "Cache-Control": "no-store" } }); + } + if (url.pathname === "/api/machine/clients" && req.method === "GET") { + return Response.json({ + selectedClients: [...state.selectedClients], + journalOwner: journalOwner(), + shim: diagnoseCodexShim(), + }, { headers: { "Cache-Control": "no-store" } }); + } + if (url.pathname === "/api/machine/sync" && req.method === "POST") { + const body = await jsonBody(req); + if (body instanceof Response) return body; + const input = strictObject(body, ["restartCodex"]); + if (!input || (input.restartCodex !== undefined && typeof input.restartCodex !== "boolean")) { + return Response.json({ error: "invalid sync request" }, { status: 400 }); + } + try { + const result = await deps.sync( + input.restartCodex === undefined ? {} : { restartCodex: input.restartCodex }, + ); + deps.setHubReachability?.("online"); + return Response.json({ success: true, ...result }); + } catch (error) { + const message = error instanceof Error ? error.message : "client sync failed"; + deps.setHubReachability?.(/unauthor/i.test(message) ? "unauthorized" : "offline"); + return Response.json({ success: false, error: message }, { status: 502 }); + } + } + if (url.pathname === "/api/machine/shim" && req.method === "GET") { + return Response.json(diagnoseCodexShim(), { headers: { "Cache-Control": "no-store" } }); + } + if (url.pathname === "/api/machine/shim" && req.method === "POST") { + const body = await jsonBody(req); + if (body instanceof Response) return body; + const input = strictObject(body, ["action"]); + if (!input || (input.action !== "install" && input.action !== "repair" && input.action !== "uninstall")) { + return Response.json({ error: "action must be install, repair, or uninstall" }, { status: 400 }); + } + try { + const result = input.action === "uninstall" ? uninstallCodexShim() : installCodexShim(); + return Response.json({ success: true, action: input.action, result, shim: diagnoseCodexShim() }); + } catch (error) { + return Response.json({ success: false, error: error instanceof Error ? error.message : "shim action failed" }, { status: 409 }); + } + } + if (url.pathname === "/api/machine/disconnect" && req.method === "POST") { + const body = await jsonBody(req); + if (body instanceof Response) return body; + const input = strictObject(body, ["keepCatalog"]); + if (!input || (input.keepCatalog !== undefined && typeof input.keepCatalog !== "boolean")) { + return Response.json({ error: "invalid disconnect request" }, { status: 400 }); + } + try { + const result = await deps.disconnect(input.keepCatalog === undefined ? {} : { keepCatalog: input.keepCatalog }); + deps.scheduleStandaloneRecycle(); + return Response.json({ success: true, ...result }, { status: 202 }); + } catch (error) { + return Response.json({ success: false, error: error instanceof Error ? error.message : "disconnect failed" }, { status: 409 }); + } + } + return null; +} diff --git a/src/client/machine-auth.ts b/src/client/machine-auth.ts new file mode 100644 index 0000000000..f2aad27499 --- /dev/null +++ b/src/client/machine-auth.ts @@ -0,0 +1,54 @@ +import type { OcxConfig } from "../types"; +import { + managementPrincipal, + requireManagementAuth, + type ManagementAuthState, +} from "../server/management-auth"; + +export const MACHINE_SESSION_HEADER = "x-opencodex-machine-session"; +export const MACHINE_GUI_ORIGIN_HEADER = "x-opencodex-machine-gui-origin"; +export const MACHINE_CSRF_HEADER = "x-opencodex-machine-csrf-token"; + +const MACHINE_AUTH_HEADERS = [ + MACHINE_SESSION_HEADER, + MACHINE_GUI_ORIGIN_HEADER, + MACHINE_CSRF_HEADER, +] as const; + +function machinePrincipalRequest(req: Request): Request { + const headers = new Headers(req.headers); + const token = headers.get(MACHINE_SESSION_HEADER); + const browserOrigin = headers.get(MACHINE_GUI_ORIGIN_HEADER); + const csrf = headers.get(MACHINE_CSRF_HEADER); + headers.delete("authorization"); + headers.delete("x-api-key"); + headers.delete("x-opencodex-api-key"); + headers.delete("x-opencodex-gui-origin"); + headers.delete("x-opencodex-csrf-token"); + if (token) headers.set("x-opencodex-api-key", token); + if (browserOrigin) { + headers.set("x-opencodex-gui-origin", browserOrigin); + headers.set("Origin", browserOrigin); + } + if (csrf) headers.set("x-opencodex-csrf-token", csrf); + return new Request(req.url, { method: req.method, headers, signal: req.signal }); +} + +export function requireMachineAuth( + req: Request, + state: ManagementAuthState, + config: OcxConfig, +): Response | null { + const synthetic = machinePrincipalRequest(req); + const error = requireManagementAuth(synthetic, state, config); + if (error) return error; + return managementPrincipal(synthetic, state, config) === "gui-session" + ? null + : Response.json({ error: "opencodex machine GUI session required" }, { status: 401 }); +} + +export function stripMachineAuthHeaders(headers: Headers): Headers { + const stripped = new Headers(headers); + for (const name of MACHINE_AUTH_HEADERS) stripped.delete(name); + return stripped; +} diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts new file mode 100644 index 0000000000..476c2e70a4 --- /dev/null +++ b/src/client/machine-listener.ts @@ -0,0 +1,136 @@ +import { readFileSync } from "node:fs"; +import type { Server } from "bun"; +import { loadConfig } from "../config"; +import { browserSecurityHeaders } from "../server/auth-cors"; +import { serveGuiFile, serveSessionBootstrap } from "../server/gui-static"; +import { + initializeManagementAuthState, + issueGuiSession, + managementPrincipal, + requireManagementAuth, + type ManagementAuthState, +} from "../server/management-auth"; +import type { OcxClientConnectionConfig, OcxConfig } from "../types"; +import { disconnectClient, syncConnectedClient } from "./connect"; +import { readClientConnectionState } from "./state"; +import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api"; +import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth"; +import { relayHubManagementRequest } from "./hub-relay"; + +const VERSION = (() => { + try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string; } + catch { return "0.0.0"; } +})(); +const GUI_SPA_PATHS = new Set([ + "/dashboard", "/startup", "/providers", "/models", "/subagents", + "/logs", "/usage", "/storage", "/codex-set", "/integrations", +]); + +export interface MachineListenerDeps { + state?: OcxClientConnectionConfig; + managementAuthState?: ManagementAuthState; + fetchImpl?: typeof fetch; + machineApi?: Partial; +} + +function json404(req: Request): Response { + const url = new URL(req.url); + return Response.json({ error: "not_found", method: req.method, path: url.pathname }, { status: 404 }); +} + +function machinePolicyConfig(config: OcxConfig): OcxConfig { + return { ...config, hostname: "127.0.0.1" }; +} + +export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolean): boolean { + if (req.headers.get("upgrade")) return false; + const path = url.pathname; + if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true; + if (req.method === "GET" && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true; + if (req.method === "POST" && (path === "/api/machine/sync" || path === "/api/machine/shim" || path === "/api/machine/disconnect")) return true; + if (relayEnabled && path.startsWith("/api/machine/hub-relay/")) return true; + if (req.method !== "GET" || path.startsWith("/api/") || path.startsWith("/v1/")) return false; + return GUI_SPA_PATHS.has(path) + || path.startsWith("/integrations/") + || /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path); +} + +export function startMachineListener( + port?: number, + deps: MachineListenerDeps = {}, +): Server { + const config = machinePolicyConfig(loadConfig()); + const connection = deps.state ?? (() => { + const state = readClientConnectionState(); + if (state.kind !== "connected") throw new Error(`machine listener requires connected client state, got ${state.kind}`); + return state.value; + })(); + const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config); + let hubReachability: HubReachability = "unknown"; + const machineApiDeps: MachineApiDeps = { + sync: deps.machineApi?.sync ?? syncConnectedClient, + disconnect: deps.machineApi?.disconnect ?? disconnectClient, + scheduleStandaloneRecycle: deps.machineApi?.scheduleStandaloneRecycle ?? (() => { + void import("./runtime").then(module => module.scheduleStandaloneRecycle()); + }), + hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability), + setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }), + }; + const relayEnabled = connection.managementTransport === "relay"; + + return Bun.serve({ + port: port ?? config.port ?? 10100, + hostname: "127.0.0.1", + async fetch(req, server) { + const url = new URL(req.url); + if (!machineRouteAllowed(url, req, relayEnabled)) return json404(req); + if (url.pathname === "/healthz" && req.method === "GET") { + return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port }); + } + if (url.pathname === "/readyz" && req.method === "GET") { + return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 }); + } + if (url.pathname.startsWith("/api/machine/hub-relay/")) { + if (!relayEnabled) return json404(req); + const authError = requireMachineAuth(req, managementAuth, config); + if (authError) return authError; + const prefix = "/api/machine/hub-relay"; + const suffix = `${url.pathname.slice(prefix.length)}${url.search}`; + const response = await relayHubManagementRequest(req, suffix, { + managementUrl: connection.managementUrl, + browserOrigin: req.headers.get(MACHINE_GUI_ORIGIN_HEADER) ?? req.headers.get("Origin") ?? "", + }, { fetchImpl: deps.fetchImpl }); + if (response.status === 401) hubReachability = "unauthorized"; + else if (response.status >= 500) hubReachability = "offline"; + else hubReachability = "online"; + return response; + } + if (url.pathname.startsWith("/api/machine/")) { + const authError = requireManagementAuth(req, managementAuth, config); + if (authError) return authError; + if (managementPrincipal(req, managementAuth, config) !== "gui-session") { + return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 }); + } + return await handleMachineApi(req, url, connection, machineApiDeps) ?? json404(req); + } + + const session = (url.pathname === "/" || url.pathname === "/opencodex-session") + ? issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false }) + : null; + if (url.pathname === "/opencodex-session" && session) return serveSessionBootstrap(session); + const gui = serveGuiFile(url.pathname, undefined, session ?? undefined); + if (gui) return gui; + if (url.pathname === "/") { + return Response.json({ + status: "ok", + service: "opencodex", + version: VERSION, + role: "client", + dashboard: { available: false, reason: "GUI build not found" }, + endpoints: { health: "/healthz", ready: "/readyz", machine: "/api/machine/*" }, + }, { headers: browserSecurityHeaders() }); + } + return json404(req); + }, + }); +} diff --git a/src/client/runtime.ts b/src/client/runtime.ts new file mode 100644 index 0000000000..f8eb85920a --- /dev/null +++ b/src/client/runtime.ts @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; +import type { Server } from "bun"; +import { loadConfig } from "../config"; +import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state"; +import { installCrashGuards } from "../lib/crash-guard"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { findAvailablePort } from "../server/ports"; +import { startMachineListener } from "./machine-listener"; +import { readClientConnectionState } from "./state"; + +let activeServer: Server | null = null; +let activePort: number | null = null; +let recycleScheduled = false; + +function cleanup(): void { + removePid(process.pid); + removeRuntimePort(process.pid); +} + +export function scheduleStandaloneRecycle(): void { + if (recycleScheduled) return; + recycleScheduled = true; + const timer = setTimeout(() => { + const port = activePort; + try { activeServer?.stop(true); } catch { /* best effort */ } + cleanup(); + // Recycling back to standalone after `ocx disconnect` must actually bring a standalone + // proxy back, under either launch shape. + // + // Unsupervised: spawn the replacement ourselves and exit 0. + // + // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a + // second copy would fight it for the port. But exit 0 does not work either: the real + // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW + // ``, the Task Scheduler ERRORLEVEL loop), so a clean exit + // reads as "the service finished" and nothing restarts. The client stayed down until the + // operator noticed. Exit 1 is what those configs are watching for, and it is the same + // policy the dashboard recycle already uses (src/server/management/system-restart.ts). + // + // launchd's KeepAlive restarts on any exit, so it is correct under both branches. + if (process.env.OCX_SERVICE === "1") { + process.exit(1); + } + if (port) { + const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env }, + }); + child.unref(); + } + process.exit(0); + }, 50); + if (typeof timer === "object" && "unref" in timer) timer.unref(); +} + +export async function startClientRuntime( + options: { port?: number; block?: boolean } = {}, +): Promise { + const state = readClientConnectionState(); + if (state.kind !== "connected") throw new Error(`client runtime refused: client state is ${state.kind}`); + const config = loadConfig(); + const preferred = options.port ?? config.port ?? 10100; + const port = await findAvailablePort(preferred, "127.0.0.1", { + preferRetryMs: options.port === undefined ? 750 : 5_000, + preferRetryIntervalMs: 50, + allowEphemeralFallback: options.port === undefined, + }); + const server = startMachineListener(port, { state: state.value }); + const boundPort = server.port ?? port; + activeServer = server; + activePort = boundPort; + installCrashGuards(); + writePid(process.pid); + writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" }); + + let shuttingDown = false; + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + try { server.stop(true); } finally { + cleanup(); + process.exit(0); + } + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + if (process.platform !== "win32") process.on("SIGHUP", shutdown); + process.on("exit", cleanup); + + if (options.block ?? true) await new Promise(() => {}); +} diff --git a/src/config.ts b/src/config.ts index 0755124640..9173c4b353 100644 --- a/src/config.ts +++ b/src/config.ts @@ -935,7 +935,7 @@ const clientConnectionSchema = z.object({ tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/), protocolVersion: z.literal(1), connectedAt: clientTimestampSchema, - catalogEtag: z.string().min(1).max(512).optional(), + catalogFingerprint: z.string().min(1).max(512).optional(), // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the // catalog size cap so a legitimate snapshot round-trips. priorCatalog: z.string().max(64 * 1024 * 1024).optional(), diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index c93299da3c..3d97ce451d 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -75,6 +75,21 @@ function sessionBootstrapMeta(session: GuiSessionBootstrap): string { ].join(""); } +/** + * Runtime role, emitted on every served document. + * + * Separate from the session block on purpose: the session exists only once a GUI session + * has been issued, but the role has to be known on the very first paint of a plain + * standalone install — which never issues one. Without it the GUI has to ASK, and asking + * means a request to a remote-hub endpoint from a user who never enabled remote hub. + * + * Non-secret: it names which topology this proxy is running, which the operator configured + * and which the dashboard already reflects everywhere else. + */ +function runtimeRoleMeta(runtimeRole: string): string { + return ``; +} + function htmlDocumentResponse(html: string): Response { return new Response(html, { headers: { @@ -86,10 +101,10 @@ function htmlDocumentResponse(html: string): Response { }); } -function htmlResponse(path: string, session?: GuiSessionBootstrap): Response { +function htmlResponse(path: string, session?: GuiSessionBootstrap, runtimeRole?: string): Response { let html = readFileSync(path, "utf8"); - if (session) { - const bootstrap = sessionBootstrapMeta(session); + const bootstrap = `${runtimeRole ? runtimeRoleMeta(runtimeRole) : ""}${session ? sessionBootstrapMeta(session) : ""}`; + if (bootstrap) { html = html.includes("") ? html.replace("", `${bootstrap}`) : `${bootstrap}${html}`; } return htmlDocumentResponse(html); @@ -110,6 +125,7 @@ export function serveGuiFile( pathname: string, guiDist = findGuiDist(), session?: GuiSessionBootstrap, + runtimeRole?: string, ): Response | null { if (!guiDist) return null; const filePath = resolveGuiFilePath(guiDist, pathname); @@ -119,7 +135,7 @@ export function serveGuiFile( if (!extname(pathname)) { const indexPath = join(guiDist, "index.html"); if (isFile(indexPath)) { - return htmlResponse(indexPath, session); + return htmlResponse(indexPath, session, runtimeRole); } } return null; @@ -127,7 +143,7 @@ export function serveGuiFile( const ext = extname(filePath); const contentType = MIME_TYPES[ext] || "application/octet-stream"; - if (ext === ".html") return htmlResponse(filePath, session); + if (ext === ".html") return htmlResponse(filePath, session, runtimeRole); // Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced // after Bun frames the response but before the stream finishes, its Content-Length can // describe the old file while the body comes from the new one (#2792). diff --git a/src/server/index.ts b/src/server/index.ts index 18ca92c5ea..f96b77b62e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1894,7 +1894,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(summary: T, entries?: PersistedUsageEntry[]) => projectUsageSummary(summary, filter, entries); - const filterRequested = Boolean(filter.provider ?? filter.model); + const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.apiKeyId); const now = Date.now(); try { const cacheKey = `${range}:${surface}`; diff --git a/src/types/config.ts b/src/types/config.ts index 0c867abfa8..7a4524b0fa 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -280,7 +280,14 @@ export interface OcxClientConnectionConfig { tokenFingerprint: string; protocolVersion: 1; connectedAt: string; - catalogEtag?: string; + /** + * sha256/base64url of the catalog bytes this connection wrote, used to tell "still ours" + * from "edited or replaced" before removing the file on disconnect. + * + * Our own hash rather than the hub's ETag: /v1/catalog emits no validator, and this was + * always an ownership check on local bytes rather than a cache concern. + */ + catalogFingerprint?: string; /** * The catalog that was on disk before connect overwrote it, base64-encoded, or the * empty string when there was none. diff --git a/src/usage/summary.ts b/src/usage/summary.ts index cc3161aa16..b37aac8533 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -156,6 +156,7 @@ export interface UsageSummary { export interface UsageFilterEcho { provider: string | null; model: string | null; + apiKeyId: string | null; matched: boolean; /** * True when a retained row came from a combo attribution. Cost partitions @@ -1129,6 +1130,11 @@ function normalizeFilterValue(input: string | null | undefined): string | null { return trimmed === "" ? null : trimmed.toLowerCase(); } +function normalizeExactFilterValue(input: string | null | undefined): string | null { + const trimmed = typeof input === "string" ? input.trim() : ""; + return trimmed === "" ? null : trimmed; +} + /** * Narrow an already-summarised window to one provider and/or model. * @@ -1152,23 +1158,51 @@ function normalizeFilterValue(input: string | null | undefined): string | null { */ export function projectUsageSummary( summary: T, - filter: { provider?: string | null; model?: string | null }, + filter: { provider?: string | null; model?: string | null; apiKeyId?: string | null }, entries?: PersistedUsageEntry[], ): T & { filter?: UsageFilterEcho } { const provider = normalizeFilterValue(filter.provider); const model = normalizeFilterValue(filter.model); - if (provider === null && model === null) return summary; + const apiKeyId = normalizeExactFilterValue(filter.apiKeyId); + if (provider === null && model === null && apiKeyId === null) return summary; + + // Re-summarise from the entries the summary was built from, rather than + // projecting over its rows. + // + // Projecting rows looked cheaper and was wrong in three ways that only show + // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are + // collapsed into a synthetic "other" row, so a provider living only in that + // tail is unfindable and reports matched:false despite real usage; a + // provider row is a whole-provider aggregate, so a model filter kept the + // provider's OTHER models in providers[] while models[] and the totals + // excluded them, contradicting itself inside one response; and a model row + // carries a single optional cost, so priced/unpriced/unmetered counts could + // only be guessed per model rather than counted per request. + // + // Key ownership is the outer slice: no provider/model attribution or bucket + // construction may observe rows belonging to another client key. + const keyFilteredEntries = apiKeyId === null + ? entries ?? [] + : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId); + // The entries are already in hand on every path that filters, so the honest + // computation is also the simple one. const matches = (rowProvider: string, rowModel: string): boolean => { if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false; if (model !== null && rowModel.toLowerCase() !== model) return false; return true; }; - const source = entries ?? []; + // Narrow to matching ATTRIBUTIONS, not matching entries. + // + // Keeping a whole combo entry because one of its attempts matched drags the + // other attempts' tokens and cost into the filtered totals: a two-attempt + // combo filtered to its cheap model reported the expensive model's spend + // too. Rewriting the entry down to its matching attempts is what makes the + // filtered numbers mean what the flag says. let comboOverlap = false; const filtered: PersistedUsageEntry[] = []; - for (const entry of source) { + for (const entry of keyFilteredEntries) { if (!entry.attempts?.length) { const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); if (matches(entry.provider, identity.model)) filtered.push(entry); @@ -1194,7 +1228,15 @@ export function projectUsageSummary( days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })), models, providers: projected.providers.filter(row => retainedProviders.has(row.provider)), - accounts: [], - filter: { provider, model, matched, comboOverlap }, + // Account rows are not provider-partitioned in a way this projection could + // honestly re-derive, and unfiltered account totals sitting beside filtered + // model totals would invite exactly the wrong reading — so a provider or model + // filter drops them. + // + // An apiKeyId-only filter is different: it selects whole entries, so the account + // rows projected from those entries are exactly the accounts that key used. They + // are honest under that filter and are kept. + accounts: provider === null && model === null ? projected.accounts : [], + filter: { provider, model, apiKeyId, matched, comboOverlap }, }; } diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 781412a331..21d8e1db63 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -592,6 +592,40 @@ describe("GET /api/usage", () => { } }); + test("apiKeyId is an exact projection and composes with provider and model filters", async () => { + const now = Date.now(); + const rows = [ + { requestId: "a-openai", timestamp: now, apiKeyId: "Key-A", provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 2 }, totalTokens: 12 }, + { requestId: "a-anthropic", timestamp: now, apiKeyId: "Key-A", provider: "anthropic", model: "claude-x", status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 20, outputTokens: 3 }, totalTokens: 23 }, + { requestId: "b", timestamp: now, apiKeyId: "key-a", provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 30, outputTokens: 4 }, totalTokens: 34 }, + { requestId: "legacy", timestamp: now, provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", usage: { inputTokens: 40, outputTokens: 5 }, totalTokens: 45 }, + ]; + writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); + const server = startServer(0); + try { + const own = await fetch(new URL("/api/usage?range=all&apiKeyId=Key-A", server.url)).then(res => res.json()); + expect(own.filter).toMatchObject({ apiKeyId: "Key-A", provider: null, model: null, matched: true }); + expect(own.summary.requests).toBe(2); + + const combined = await fetch(new URL("/api/usage?range=all&apiKeyId=Key-A&provider=openai&model=gpt-5.5", server.url)).then(res => res.json()); + expect(combined.summary.requests).toBe(1); + expect(combined.models).toHaveLength(1); + + const exactCase = await fetch(new URL("/api/usage?range=all&apiKeyId=key-a", server.url)).then(res => res.json()); + expect(exactCase.summary.requests).toBe(1); + + const missing = await fetch(new URL("/api/usage?range=all&apiKeyId=missing", server.url)).then(res => res.json()); + expect(missing.filter).toMatchObject({ apiKeyId: "missing", matched: false }); + expect(missing.summary.requests).toBe(0); + + const unfiltered = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + expect(unfiltered.filter).toBeUndefined(); + expect(unfiltered.summary.requests).toBe(4); + } finally { + await server.stop(true); + } + }); + test("the filter is applied on the cache-hit path too", async () => { writeFixture(Date.now()); const server = startServer(0); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index f16742c3ea..2c2b6bd1bf 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -260,6 +260,13 @@ describe("headless GUI parity CLI", () => { ["/api/logs", "ocx observe"], ["/api/lab", "ocx lab"], ["/api/config", "ocx config"], + // The client machine plane. These are served by the connected client's own loopback + // listener rather than the hub, and each one mirrors a connect-family command: + // status/clients -> `ocx connect status`, sync -> `ocx sync`, shim -> the client + // integration commands, disconnect -> `ocx disconnect`. hub-relay is the fixed-target + // relay those same commands use to reach the hub, so it has no separate CLI verb of + // its own — it is the transport selected by `--management-transport relay`. + ["/api/machine", "ocx connect/disconnect/sync"], // The prompt composer is a GUI-first surface: it reads Codex's own layer // inventory and writes one config key. There is no headless equivalent // today, and claiming one would be worse than saying so here. diff --git a/tests/cli-start-journal-order.test.ts b/tests/cli-start-journal-order.test.ts index 4ca5a077c1..712af88ce4 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -184,10 +184,40 @@ describe("start and ensure journal ownership (#1230)", () => { timestamp: new Date().toISOString(), })); - const result = await runCli(fx, ["start"]); - expect(result.exitCode).toBe(1); - expect(readFileSync(fx.configPath, "utf8")).toBe(matches ? injected : original); - expect(existsSync(fx.journalPath)).toBe(matches); + const child = Bun.spawn([process.execPath, cliPath, "start"], { + cwd: fx.root, + env: fx.env, + stdout: "pipe", + stderr: "pipe", + }); + children.push(child); + const runtimePath = join(fx.ocxHome, "runtime-port.json"); + const runtime = await waitFor(async () => { + if (!existsSync(runtimePath)) { + if (child.exitCode === null) return null; + const [stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + throw new Error(`connected client exited ${child.exitCode}: ${stderr || stdout}`); + } + try { + const value = JSON.parse(readFileSync(runtimePath, "utf8")) as { pid?: number; port?: number; hostname?: string }; + return value.pid === child.pid && typeof value.port === "number" && value.port > 0 ? value : null; + } catch { return null; } + }, "connected client runtime record"); + try { + const health = await fetch(`http://127.0.0.1:${runtime.port}/healthz`).then(response => response.json()) as { role?: string }; + expect(health.role).toBe("client"); + expect(runtime.hostname).toBe("127.0.0.1"); + expect((await fetch(`http://127.0.0.1:${runtime.port}/v1/models`)).status).toBe(404); + expect((await fetch(`http://127.0.0.1:${runtime.port}/api/config`)).status).toBe(404); + expect(readFileSync(fx.configPath, "utf8")).toBe(matches ? injected : original); + expect(existsSync(fx.journalPath)).toBe(matches); + } finally { + child.kill("SIGTERM"); + await child.exited; + } } }, 30_000); diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 5d7edbb0f1..00d236c71c 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -103,24 +103,34 @@ describe("remote hub client boundary", () => { expect(seen[1]?.body).toBe(JSON.stringify({ name: "client" })); }); - test("pairing HTTP requires explicit client opt-in and catalog is bounded/conditional", async () => { + test("plaintext HTTP cannot carry a pairing grant, with no opt-in and no request sent", async () => { + // An earlier revision accepted `--allow-insecure-http` here and this test asserted the + // opt-in message. The option is gone: the hub refuses the exchange outright, so sending + // it would only burn a single-use code against a certain rejection. let calls = 0; await expect(exchangeConnectPairingGrant( "http://hub.example.test", "http://localhost:10100", new TextEncoder().encode(`ocx_pair_${"c".repeat(43)}`), { fetchImpl: async () => { calls += 1; return new Response(); } }, - )).rejects.toThrow("--allow-insecure-http"); + )).rejects.toThrow("loopback or HTTPS"); + // Refused before any request: the grant is still spendable over a permitted transport. expect(calls).toBe(0); + }); - const notModified = await downloadClientCatalog("https://hub.example.test", "ocx_data_test", { - etag: '"etag"', + test("the catalog fetch is unconditional and still bounded", async () => { + // /v1/catalog emits no validator (Phase 1, D2), so the client sends no If-None-Match and + // has no 304 branch to keep correct. The size bound is unaffected by that change. + let sentConditional: string | null = null; + const fresh = await downloadClientCatalog("https://hub.example.test", "ocx_data_test", { fetchImpl: async (_input, init) => { - expect(new Headers(init?.headers).get("if-none-match")).toBe('"etag"'); - return new Response(null, { status: 304 }); + sentConditional = new Headers(init?.headers).get("if-none-match"); + return new Response('{"models":[]}'); }, }); - expect(notModified).toEqual({ kind: "not-modified" }); + expect(sentConditional).toBeNull(); + expect(fresh).toMatchObject({ kind: "fresh" }); + await expect(downloadClientCatalog("https://hub.example.test", "ocx_data_test", { maxBytes: 4, fetchImpl: async () => new Response('{"models":[]}'), @@ -302,7 +312,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const token = `ocx_data_${"e".repeat(40)}`; const fingerprint = createHash("sha256").update(token).digest("hex"); const catalog = '{"models":[]}'; - const etag = `"sha256-${createHash("sha256").update(catalog).digest("base64url")}"`; + const catalogFingerprint = createHash("sha256").update(catalog).digest("base64url"); const isDisconnect = mode === "disconnect-conflict" || mode === "disconnect-process-journal"; const selectedClients = isDisconnect ? ["codex"] : ["claude"]; writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ @@ -320,7 +330,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c tokenFingerprint: fingerprint, protocolVersion: 1, connectedAt: "2026-08-28T00:00:00.000Z", - catalogEtag: etag, + catalogFingerprint, catalogSyncedAt: "2026-08-28T00:00:00.000Z", }, }), "utf8"); diff --git a/tests/client-hub-relay.test.ts b/tests/client-hub-relay.test.ts new file mode 100644 index 0000000000..3c38a38085 --- /dev/null +++ b/tests/client-hub-relay.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { + HUB_RELAY_REQUEST_BODY_MAX_BYTES, + HUB_RELAY_RESPONSE_BODY_MAX_BYTES, + relayHubManagementRequest, +} from "../src/client/hub-relay"; + +const target = { managementUrl: "https://hub.example.test", browserOrigin: "http://127.0.0.1:10100" }; + +function relayRequest(path: string, init: RequestInit = {}): Request { + return new Request(`http://127.0.0.1:10100/api/machine/hub-relay${path}`, { + ...init, + headers: { + Origin: target.browserOrigin, + "X-OpenCodex-API-Key": "ocx_session_hub", + "X-OpenCodex-GUI-Origin": target.browserOrigin, + "X-OpenCodex-CSRF-Token": "hub-csrf", + "X-OpenCodex-Machine-Session": "ocx_session_machine", + "X-OpenCodex-Machine-GUI-Origin": target.browserOrigin, + "X-OpenCodex-Machine-CSRF-Token": "machine-csrf", + Cookie: "private=1", + Forwarded: "for=192.0.2.1", + Connection: "keep-alive", + ...init.headers, + }, + }); +} + +describe("fixed-target hub management relay", () => { + test("forwards only to the configured hub and strips machine, cookie, forwarding, and hop headers", async () => { + let captured: { url: string; headers: Headers } | null = null; + const response = await relayHubManagementRequest(relayRequest("/api/usage?range=all"), "/api/usage?range=all", target, { + fetchImpl: (async (input, init) => { + captured = { url: String(input), headers: new Headers(init?.headers) }; + return Response.json({ ok: true }, { headers: { "Set-Cookie": "hub=secret", Connection: "close", ETag: "v1" } }); + }) as typeof fetch, + }); + expect(response.status).toBe(200); + expect(captured!.url).toBe("https://hub.example.test/api/usage?range=all"); + expect(captured!.headers.get("x-opencodex-api-key")).toBe("ocx_session_hub"); + for (const header of ["x-opencodex-machine-session", "cookie", "forwarded", "connection", "host"]) { + expect(captured!.headers.get(header)).toBeNull(); + } + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("connection")).toBeNull(); + expect(response.headers.get("etag")).toBe("v1"); + }); + + test("POST pairing reaches only /opencodex-session and forwards browser Origin verbatim", async () => { + let captured: { url: string; method: string; origin: string | null } | null = null; + const request = relayRequest("/opencodex-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ grant: `ocx_pair_${"a".repeat(43)}` }), + }); + const response = await relayHubManagementRequest(request, "/opencodex-session", target, { + fetchImpl: (async (input, init) => { + captured = { url: String(input), method: String(init?.method), origin: new Headers(init?.headers).get("origin") }; + return new Response("", { headers: { "Content-Type": "text/html" } }); + }) as typeof fetch, + }); + expect(response.status).toBe(200); + expect(captured).toEqual({ url: "https://hub.example.test/opencodex-session", method: "POST", origin: target.browserOrigin }); + }); + + test("rejects traversal, authority, encoded separator, and caller-host variants before outbound I/O", async () => { + let calls = 0; + const fetchImpl = (async () => { calls += 1; return new Response(); }) as typeof fetch; + for (const suffix of [ + "//evil.example/api/config", + "/api/../opencodex-session", + "/api/%2e%2e/opencodex-session", + "/api/%2f%2fevil.example/config", + "/api/%5cevil", + "https://evil.example/api/config", + "/v1/models", + "/opencodex-session?host=evil.example", + ]) { + const response = await relayHubManagementRequest(relayRequest("/api/config"), suffix, target, { fetchImpl }); + expect(response.status).toBe(404); + } + expect(calls).toBe(0); + }); + + test("rejects redirects, request and response overflow, and timeout without exposing bodies", async () => { + const redirected = await relayHubManagementRequest(relayRequest("/api/config"), "/api/config", target, { + fetchImpl: (async () => new Response(null, { status: 302, headers: { Location: "https://evil.example" } })) as typeof fetch, + }); + expect(redirected.status).toBe(502); + + const oversizedRequest = relayRequest("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": String(HUB_RELAY_REQUEST_BODY_MAX_BYTES + 1) }, + body: "{}", + }); + let calls = 0; + expect((await relayHubManagementRequest(oversizedRequest, "/api/config", target, { + fetchImpl: (async () => { calls += 1; return new Response(); }) as typeof fetch, + })).status).toBe(413); + expect(calls).toBe(0); + + const oversizedResponse = await relayHubManagementRequest(relayRequest("/api/config"), "/api/config", target, { + fetchImpl: (async () => new Response("x", { headers: { "Content-Length": String(HUB_RELAY_RESPONSE_BODY_MAX_BYTES + 1) } })) as typeof fetch, + }); + expect(oversizedResponse.status).toBe(502); + + const timedOut = await relayHubManagementRequest(relayRequest("/api/config"), "/api/config", target, { + timeoutMs: 5, + fetchImpl: (async (_input, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + })) as typeof fetch, + }); + expect(timedOut.status).toBe(502); + }); +}); diff --git a/tests/client-machine-listener.test.ts b/tests/client-machine-listener.test.ts new file mode 100644 index 0000000000..511961c8bd --- /dev/null +++ b/tests/client-machine-listener.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Server } from "bun"; +import { startMachineListener } from "../src/client/machine-listener"; +import type { OcxClientConnectionConfig } from "../src/types"; +import type { ManagementAuthState } from "../src/server/management-auth"; + +let root = ""; +let previousHome: string | undefined; +const servers: Server[] = []; + +const connection = (transport: "direct" | "relay" = "direct"): OcxClientConnectionConfig => ({ + serverUrl: "https://hub.example.test", + managementUrl: "https://hub.example.test", + managementTransport: transport, + selectedClients: ["codex"], + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + apiKeyId: "client-key-a", + tokenFingerprint: "a".repeat(64), + protocolVersion: 1, + connectedAt: "2026-08-28T00:00:00.000Z", + catalogSyncedAt: "2026-08-28T00:01:00.000Z", +}); + +function authState(): ManagementAuthState { + return { + available: true, + token: `ocx_admin_${"a".repeat(43)}`, + source: "environment", + sessions: new Map(), + pairingGrants: new Map(), + }; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + root = mkdtempSync(join(tmpdir(), "ocx-machine-listener-")); + process.env.OPENCODEX_HOME = root; + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "config.json"), JSON.stringify({ + port: 0, + hostname: "0.0.0.0", + providers: {}, + defaultProvider: "openai", + })); +}); + +afterEach(async () => { + for (const server of servers.splice(0)) await server.stop(true); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (root) rmSync(root, { recursive: true, force: true }); +}); + +function meta(html: string, name: string): string { + const match = new RegExp(`, mutation = false): Promise { + const bootstrap = await fetch(new URL("/opencodex-session", server.url)); + const html = await bootstrap.text(); + const headers = new Headers({ + "X-OpenCodex-API-Key": meta(html, "opencodex-session-token"), + "X-OpenCodex-GUI-Origin": meta(html, "opencodex-session-origin"), + }); + if (mutation) { + headers.set("Origin", meta(html, "opencodex-session-origin")); + headers.set("X-OpenCodex-CSRF-Token", meta(html, "opencodex-session-csrf")); + headers.set("Content-Type", "application/json"); + } + return headers; +} + +describe("client machine listener", () => { + test("binds IPv4 loopback and default-denies shared/data-plane routes", async () => { + const server = startMachineListener(0, { state: connection(), managementAuthState: authState() }); + servers.push(server); + expect(server.hostname).toBe("127.0.0.1"); + expect((await fetch(new URL("/healthz", server.url))).status).toBe(200); + expect((await fetch(new URL("/readyz", server.url))).status).toBe(200); + expect((await fetch(new URL("/opencodex-session", server.url))).headers.get("content-type")).toContain("text/html"); + for (const path of [ + "/v1/responses", "/v1/models", "/v1/catalog", "/api/config", "/api/usage", + "/api/oauth/providers", "/lab", "/oauth/callback", "/api/machine/unknown", + ]) { + const response = await fetch(new URL(path, server.url), { method: path === "/v1/responses" ? "POST" : "GET" }); + expect(response.status).toBe(404); + expect((await response.json()).error).toBe("not_found"); + } + expect((await fetch(new URL("/api/machine/hub-relay/api/config", server.url))).status).toBe(404); + expect((await fetch(new URL("/api/machine/status", server.url), { method: "POST" })).status).toBe(404); + }); + + test("requires a GUI session for safe reads and Origin plus CSRF for mutations", async () => { + let syncCalls = 0; + const server = startMachineListener(0, { + state: connection(), + managementAuthState: authState(), + machineApi: { + sync: async () => { syncCalls += 1; return { catalogWritten: false, cacheSynced: true, injected: true, stale: false }; }, + }, + }); + servers.push(server); + const statusUrl = new URL("/api/machine/status", server.url); + expect((await fetch(statusUrl)).status).toBe(401); + expect((await fetch(statusUrl, { headers: { "X-OpenCodex-API-Key": `ocx_admin_${"a".repeat(43)}` } })).status).toBe(401); + + const safeHeaders = await guiHeaders(server); + const status = await fetch(statusUrl, { headers: safeHeaders }); + expect(status.status).toBe(200); + const body = await status.json(); + expect(body).toMatchObject({ mode: "client", connected: true, apiKeyId: "client-key-a", managementTransport: "direct" }); + const serialized = JSON.stringify(body); + expect(serialized).not.toContain("tokenFingerprint"); + expect(serialized).not.toContain("a".repeat(64)); + + const syncUrl = new URL("/api/machine/sync", server.url); + expect((await fetch(syncUrl, { method: "POST", headers: safeHeaders, body: "{}" })).status).toBe(401); + expect(syncCalls).toBe(0); + const mutationHeaders = await guiHeaders(server, true); + expect((await fetch(syncUrl, { method: "POST", headers: mutationHeaders, body: "{}" })).status).toBe(200); + expect(syncCalls).toBe(1); + }); + + test("disconnect commits before 202 and schedules standalone recycle while the hub is offline", async () => { + let disconnected = false; + let recycled = false; + const server = startMachineListener(0, { + state: connection(), + managementAuthState: authState(), + machineApi: { + disconnect: async () => { + disconnected = true; + return { restored: true, tokenRemoved: true, catalogRemoved: true, apiKeyId: "client-key-a" }; + }, + scheduleStandaloneRecycle: () => { recycled = disconnected; }, + }, + }); + servers.push(server); + const response = await fetch(new URL("/api/machine/disconnect", server.url), { + method: "POST", + headers: await guiHeaders(server, true), + body: "{}", + }); + expect(response.status).toBe(202); + expect(disconnected).toBe(true); + expect(recycled).toBe(true); + }); + + test("refuses startup without matching durable connected state", () => { + expect(() => startMachineListener(0, { managementAuthState: authState() })).toThrow(/requires connected client state/); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5cb35391be..e13cd43df8 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -287,7 +287,7 @@ describe("opencodex config defaults", () => { tokenFingerprint: "a".repeat(64), protocolVersion: 1 as const, connectedAt: "2026-08-28T00:00:00.000Z", - catalogEtag: '"sha256-example"', + catalogFingerprint: "sha256-example", catalogSyncedAt: "2026-08-28T00:01:00.000Z", pendingOperation: { kind: "rotate" as const, diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 0b83e071fe..8917e05f6a 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -28,6 +28,7 @@ function entry(overrides: Partial & { ts: number }): Persis ...(rest.usage ? { usage: rest.usage } : {}), ...(rest.totalTokens !== undefined ? { totalTokens: rest.totalTokens } : {}), ...(rest.attempts ? { attempts: rest.attempts } : {}), + ...(rest.apiKeyId !== undefined ? { apiKeyId: rest.apiKeyId } : {}), }; } @@ -398,6 +399,50 @@ describe("projectUsageSummary", () => { expect(wider.summary.requests).toBe(1); expect(wider.filter?.matched).toBe(true); }); + + test("filters by exact api key id before provider and model attribution", () => { + const entries = [ + entry({ ts: at, requestId: "key-a-openai", apiKeyId: "Key-A", provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage: priced, accountLogLabel: "main" }), + entry({ ts: at + 1, requestId: "key-a-anthropic", apiKeyId: "Key-A", provider: "anthropic", model: "claude-opus", usageStatus: "reported", usage: priced, accountLogLabel: "pabc123" }), + entry({ ts: at + 2, requestId: "key-b", apiKeyId: "key-a", provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage: priced, accountLogLabel: "pffffff" }), + entry({ ts: at + 3, requestId: "legacy", provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage: priced }), + ]; + const summary = summarizeUsage(entries, "30d", at + 4); + + const byKey = projectUsageSummary(summary, { apiKeyId: " Key-A " }, entries); + expect(byKey.filter).toMatchObject({ apiKeyId: "Key-A", provider: null, model: null, matched: true }); + expect(byKey.summary.requests).toBe(2); + expect(byKey.models).toHaveLength(2); + expect(byKey.providers).toHaveLength(2); + expect(byKey.accounts.map(row => row.accountLogLabel).sort()).toEqual(["main", "pabc123"]); + + const combined = projectUsageSummary(summary, { + apiKeyId: "Key-A", + provider: "OPENAI", + model: "GPT-5.5", + }, entries); + expect(combined.summary.requests).toBe(1); + expect(combined.models).toHaveLength(1); + expect(combined.accounts).toEqual([]); + + const wrongCase = projectUsageSummary(summary, { apiKeyId: "key-a" }, entries); + expect(wrongCase.summary.requests).toBe(1); + expect(wrongCase.filter?.apiKeyId).toBe("key-a"); + }); + + test("an absent api key id excludes legacy and environment-token rows", () => { + const entries = [entry({ ts: at, requestId: "legacy", usageStatus: "reported", usage: priced })]; + const projected = projectUsageSummary( + summarizeUsage(entries, "30d", at + 1), + { apiKeyId: "missing-key" }, + entries, + ); + expect(projected.filter).toMatchObject({ apiKeyId: "missing-key", matched: false }); + expect(projected.summary.requests).toBe(0); + expect(projected.models).toEqual([]); + expect(projected.providers).toEqual([]); + expect(projected.accounts).toEqual([]); + }); }); describe("parseUsageSurface", () => {