From 1d99dd50b78fcee55f74a90f5210dcf2d69abce0 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:26:29 +0900 Subject: [PATCH 01/11] feat(two-plane): filter hub usage by client key --- src/server/management/logs-usage-routes.ts | 3 +- src/usage/summary.ts | 32 ++++++++++++--- tests/api-usage.test.ts | 34 ++++++++++++++++ tests/usage-summary.test.ts | 45 ++++++++++++++++++++++ 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 97dd327208..03d8f96f59 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -202,10 +202,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise(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/usage/summary.ts b/src/usage/summary.ts index cc3161aa16..58560c3552 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,12 +1158,13 @@ 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; const matches = (rowProvider: string, rowModel: string): boolean => { if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false; @@ -1165,7 +1172,14 @@ export function projectUsageSummary( return true; }; - const source = entries ?? []; + // The apiKeyId filter drops whole ENTRIES, because a key owns the entry rather than any + // individual attempt within it. Provider and model filters below narrow to matching + // ATTRIBUTIONS instead: keeping a whole combo entry because one attempt matched drags the + // other attempts' tokens and cost into the filtered totals, so a two-attempt combo + // filtered to its cheap model would report the expensive model's spend too. + const source = apiKeyId === null + ? entries ?? [] + : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId); let comboOverlap = false; const filtered: PersistedUsageEntry[] = []; for (const entry of source) { @@ -1194,7 +1208,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/usage-summary.test.ts b/tests/usage-summary.test.ts index 0b83e071fe..3811615a08 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: "account-a" }), + entry({ ts: at + 1, requestId: "key-a-anthropic", apiKeyId: "Key-A", provider: "anthropic", model: "claude-opus", usageStatus: "reported", usage: priced, accountLogLabel: "account-b" }), + entry({ ts: at + 2, requestId: "key-b", apiKeyId: "key-a", provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage: priced, accountLogLabel: "account-c" }), + 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(["account-a", "account-b"]); + + 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", () => { From 872b94549966f8e50c5355e14c4b1fe54dac7d2f Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:40:12 +0900 Subject: [PATCH 02/11] feat(two-plane): add client machine and hub GUI planes --- gui/src/App.tsx | 83 +++- gui/src/api-targets.ts | 119 +++++ gui/src/api.ts | 457 ++++++++---------- .../storage-workspace/StorageWorkspace.tsx | 8 +- gui/src/connect-pairing.ts | 74 +++ gui/src/i18n/de.ts | 25 + gui/src/i18n/en.ts | 25 + gui/src/i18n/fr.ts | 25 + gui/src/i18n/ja.ts | 25 + gui/src/i18n/ko.ts | 25 + gui/src/i18n/ru.ts | 25 + gui/src/i18n/tr.ts | 25 + gui/src/i18n/zh-TW.ts | 25 + gui/src/i18n/zh.ts | 25 + gui/src/pages/Integrations.tsx | 35 +- gui/src/pages/Startup.tsx | 40 +- gui/src/pages/Storage.tsx | 2 +- gui/src/pages/Usage.tsx | 44 +- gui/src/stop-proxy.ts | 6 +- gui/src/styles-usage-workspace.css | 17 + src/cli/index.ts | 12 +- src/client/hub-relay.ts | 195 ++++++++ src/client/machine-api.ts | 139 ++++++ src/client/machine-auth.ts | 54 +++ src/client/machine-listener.ts | 130 +++++ src/client/runtime.ts | 76 +++ 26 files changed, 1411 insertions(+), 305 deletions(-) create mode 100644 gui/src/api-targets.ts create mode 100644 gui/src/connect-pairing.ts create mode 100644 src/client/hub-relay.ts create mode 100644 src/client/machine-api.ts create mode 100644 src/client/machine-auth.ts create mode 100644 src/client/machine-listener.ts create mode 100644 src/client/runtime.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 73648675e8..e3d3124073 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 } from "./api"; +import { apiBaseForPlane, discoverApiTargets, 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,28 @@ export default function App() { const [theme, setTheme] = useState(readStoredTheme); const { locale, setLocale } = useI18n(); const t = useT(); + const [targets, setTargets] = useState(INITIAL_TARGETS); + const [targetsSettled, setTargetsSettled] = useState(false); + const [targetError, setTargetError] = useState(false); + const [sharedSessionReady, setSharedSessionReady] = useState(() => hasApiSession("shared")); + + useEffect(() => { + const controller = new AbortController(); + void discoverApiTargets(API_BASE, controller.signal).then(next => { + configureApiTargets(next); + setTargets(next); + 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 +151,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 +200,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; 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 +240,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 +354,27 @@ 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")}
+ ) : 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..f71fd63964 --- /dev/null +++ b/gui/src/api-targets.ts @@ -0,0 +1,119 @@ +export type ApiPlane = "machine" | "shared"; +export type SharedTransport = "same-origin" | "direct" | "relay"; + +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"); + } + 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); + 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..28ff9d0bcd 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,299 +1,257 @@ 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 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 }; + if (url.pathname.startsWith("/api/machine/") && targetMatchesUrl(targets.machine, url)) return { plane: "machine", bootstrap: false }; + if (targetMatchesUrl(targets.shared, url)) { + const base = targetAbsoluteBase(targets.shared).pathname.replace(/\/$/, ""); + const relative = url.pathname.slice(base.length) || "/"; + if (relative.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 +259,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.ts b/gui/src/connect-pairing.ts new file mode 100644 index 0000000000..13815468c7 --- /dev/null +++ b/gui/src/connect-pairing.ts @@ -0,0 +1,74 @@ +import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; +import { installApiSessionFromHtml } from "./api"; +import type { ApiTarget } from "./api-targets"; +import { useT } from "./i18n/shared"; + +const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; + +export async function submitConnectPairing( + target: ApiTarget, + grant: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const code = grant.trim(); + if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); + const response = await fetchImpl(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; +} + +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: "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 }, + createElement("label", { htmlFor: "connect-pairing-code" }, 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, + "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..9bf99a1e18 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2321,4 +2321,29 @@ 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.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..515e4759e5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2355,6 +2355,31 @@ 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.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..512db468d9 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2308,4 +2308,29 @@ export const fr: Record = { "models.aliasAuto": "auto", "models.aliasUser": "utilisateur", "models.aliasStale": "obsolète", + "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.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/ja.ts b/gui/src/i18n/ja.ts index df16c0562e..94d4218a55 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2342,4 +2342,29 @@ 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.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..1585516ac5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2343,4 +2343,29 @@ export const ko: Record = { "models.aliasAuto": "자동", "models.aliasUser": "사용자", "models.aliasStale": "오래됨", + "connection.discovering": "로컬 및 공유 대상을 확인하는 중…", + "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", + "connection.disconnect": "허브 연결 해제", + "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..8e09703701 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2344,4 +2344,29 @@ 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.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..92aec70d7c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2344,4 +2344,29 @@ 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.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..cb647d72af 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2306,4 +2306,29 @@ export const zhTW: 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.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.ts b/gui/src/i18n/zh.ts index 733ce1728e..f25ea6dd68 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2342,4 +2342,29 @@ 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.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..7f06312a77 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) { setMachineClients([]); 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..aa11a994ef 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) { setMachineShim(null); 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/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/client/hub-relay.ts b/src/client/hub-relay.ts new file mode 100644 index 0000000000..e4cde4dbb0 --- /dev/null +++ b/src/client/hub-relay.ts @@ -0,0 +1,195 @@ +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 { + 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(); + } + const body = new Uint8Array(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); + if (!browserOrigin || headers.get("origin") !== 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..2b4dd3d6db --- /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, { headers }); +} + +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..da54d0d70f --- /dev/null +++ b/src/client/machine-listener.ts @@ -0,0 +1,130 @@ +import { readFileSync } from "node:fs"; +import type { Server } from "bun"; +import { loadConfig } from "../config"; +import { browserSecurityHeaders } from "../server/auth-cors"; +import { serveGuiFile, serveSessionBootstrap, rootFallbackPayload } 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 { 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/") + || path.startsWith("/assets/") + && /\.(?: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", pid: process.pid, port: server.port }); + } + if (url.pathname === "/readyz" && req.method === "GET") { + return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", 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("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(rootFallbackPayload(), { headers: browserSecurityHeaders() }); + } + return json404(req); + }, + }); +} diff --git a/src/client/runtime.ts b/src/client/runtime.ts new file mode 100644 index 0000000000..97bc672678 --- /dev/null +++ b/src/client/runtime.ts @@ -0,0 +1,76 @@ +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; + setTimeout(() => { + const port = activePort; + try { activeServer?.stop(true); } catch { /* best effort */ } + cleanup(); + if (process.env.OCX_SERVICE !== "1" && 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).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 = preferred === 0 + ? 0 + : 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 }); + activeServer = server; + activePort = server.port; + installCrashGuards(); + writePid(process.pid); + writeRuntimePort({ pid: process.pid, port: server.port, 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(() => {}); +} From 90c7e8fac99f6c700f1fd0a96af9201adcb22b25 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:46:25 +0900 Subject: [PATCH 03/11] test(two-plane): cover machine relay and GUI routing --- gui/src/connect-pairing.ts | 7 +- gui/tests/api-auth-deadline.test.ts | 40 +++++++ gui/tests/api-auth-memory.test.ts | 55 ++++++++- gui/tests/api-targets.test.ts | 66 +++++++++++ gui/tests/apikeys-layout.test.ts | 2 +- gui/tests/app-stop.test.ts | 17 ++- gui/tests/claudecode-layout.test.ts | 2 +- gui/tests/connect-pairing.test.ts | 141 ++++++++++++++++++++++ gui/tests/integrations-routing.test.ts | 22 ++++ gui/tests/usage-layout.test.ts | 18 ++- src/client/runtime.ts | 5 +- tests/cli-start-journal-order.test.ts | 31 ++++- tests/client-hub-relay.test.ts | 115 ++++++++++++++++++ tests/client-machine-listener.test.ts | 157 +++++++++++++++++++++++++ 14 files changed, 659 insertions(+), 19 deletions(-) create mode 100644 gui/tests/api-targets.test.ts create mode 100644 gui/tests/connect-pairing.test.ts create mode 100644 tests/client-hub-relay.test.ts create mode 100644 tests/client-machine-listener.test.ts diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts index 13815468c7..488dc79e60 100644 --- a/gui/src/connect-pairing.ts +++ b/gui/src/connect-pairing.ts @@ -50,11 +50,11 @@ export function ConnectPairingForm({ } }; - return createElement("section", { className: "connect-pairing", "aria-labelledby": "connect-pairing-title" }, + 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 }, - createElement("label", { htmlFor: "connect-pairing-code" }, t("connection.pairing.code")), + 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", @@ -63,6 +63,7 @@ export function ConnectPairingForm({ autoComplete: "off", spellCheck: false, disabled: busy, + className: "input mono", "aria-invalid": error || undefined, "aria-describedby": error ? "connect-pairing-error" : undefined, }), 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..9a3ddf4fab 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; @@ -465,6 +466,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 +483,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 +498,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 +511,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..b3f9f33e74 --- /dev/null +++ b/gui/tests/api-targets.test.ts @@ -0,0 +1,66 @@ +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 previousFetch: typeof fetch; + +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"); + previousFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(globalThis, "window", { configurable: true, value: win }); +}); + +afterEach(() => { + globalThis.fetch = previousFetch; + Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow }); + 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 () => { + globalThis.fetch = (async () => { throw new TypeError("offline"); }) as typeof fetch; + await expect(discoverApiTargets("")).rejects.toThrow("local machine plane unavailable"); + }); +}); 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-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/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/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts new file mode 100644 index 0000000000..4d373cfc7a --- /dev/null +++ b/gui/tests/connect-pairing.test.ts @@ -0,0 +1,141 @@ +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/#dashboard" }); + 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"], + ]) { + 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" }); + 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"); + const { default: App } = await import("../src/App"); + 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)}`); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + input.dispatchEvent(new win.Event("change", { 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, 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, 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/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/client/runtime.ts b/src/client/runtime.ts index 97bc672678..b9b302c02e 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -20,7 +20,7 @@ function cleanup(): void { export function scheduleStandaloneRecycle(): void { if (recycleScheduled) return; recycleScheduled = true; - setTimeout(() => { + const timer = setTimeout(() => { const port = activePort; try { activeServer?.stop(true); } catch { /* best effort */ } cleanup(); @@ -34,7 +34,8 @@ export function scheduleStandaloneRecycle(): void { child.unref(); } process.exit(0); - }, 50).unref(); + }, 50); + if (typeof timer === "object" && "unref" in timer) timer.unref(); } export async function startClientRuntime( diff --git a/tests/cli-start-journal-order.test.ts b/tests/cli-start-journal-order.test.ts index 4ca5a077c1..c8ac68e9d8 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -184,10 +184,33 @@ 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(() => { + if (!existsSync(runtimePath)) return null; + 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-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/); + }); +}); From ccf319ce6f321a06f917403f0414100af6ebe38d Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:47:07 +0900 Subject: [PATCH 04/11] test(two-plane): align shared-base shell assertions --- gui/tests/app-sidebar-actions.test.ts | 3 +-- gui/tests/codex-stale-banner.test.ts | 2 +- gui/tests/sidebar-codex-set.test.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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/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/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 () => { From 4f27ee817e531708d075af4821c796100b988369 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:51:24 +0900 Subject: [PATCH 05/11] feat(two-plane): harden relay and offline target states --- gui/src/App.tsx | 16 +++++++++++++--- gui/src/api-targets.ts | 12 ++++++++++++ gui/src/api.ts | 16 ++++++++++------ gui/src/connect-pairing.ts | 1 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Integrations.tsx | 2 +- gui/src/pages/Startup.tsx | 2 +- gui/tests/connect-pairing.test.ts | 1 + src/client/hub-relay.ts | 4 +++- src/client/machine-auth.ts | 2 +- src/client/machine-listener.ts | 22 ++++++++++++++-------- 19 files changed, 66 insertions(+), 21 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index e3d3124073..014aec7e46 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -15,7 +15,7 @@ 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 { configureApiTargets, hasApiSession, installApiAuthFetch } from "./api"; +import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml } from "./api"; import { apiBaseForPlane, discoverApiTargets, standaloneApiTargets, type ApiTargets } from "./api-targets"; import { ConnectPairingForm } from "./connect-pairing"; import { type Page } from "./app-routing"; @@ -111,9 +111,19 @@ export default function App() { useEffect(() => { const controller = new AbortController(); - void discoverApiTargets(API_BASE, controller.signal).then(next => { + 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); @@ -205,7 +215,7 @@ export default function App() { }); 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(machineBase, { formatFailure: status => t("dash.stopFailed", { status: String(status) }), diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts index f71fd63964..000a32a058 100644 --- a/gui/src/api-targets.ts +++ b/gui/src/api-targets.ts @@ -92,6 +92,18 @@ export function targetsFromMachineStatus(initialBase: string, status: MachineSta 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") diff --git a/gui/src/api.ts b/gui/src/api.ts index 28ff9d0bcd..e6893d4c2f 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -146,6 +146,12 @@ function targetMatchesUrl(target: ApiTarget, url: URL): boolean { 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 { @@ -154,12 +160,10 @@ function classify(input: RequestInfo | URL): { plane: ApiPlane; bootstrap: boole 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 }; - if (url.pathname.startsWith("/api/machine/") && targetMatchesUrl(targets.machine, url)) return { plane: "machine", bootstrap: false }; - if (targetMatchesUrl(targets.shared, url)) { - const base = targetAbsoluteBase(targets.shared).pathname.replace(/\/$/, ""); - const relative = url.pathname.slice(base.length) || "/"; - if (relative.startsWith("/api/")) 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; } diff --git a/gui/src/connect-pairing.ts b/gui/src/connect-pairing.ts index 488dc79e60..6c1aa5520a 100644 --- a/gui/src/connect-pairing.ts +++ b/gui/src/connect-pairing.ts @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- pairing transport and its form share one session-install boundary */ import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; import { installApiSessionFromHtml } from "./api"; import type { ApiTarget } from "./api-targets"; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9bf99a1e18..18db779899 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2324,6 +2324,7 @@ export const de: Record = { "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 515e4759e5..dba1942dfd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2358,6 +2358,7 @@ export const en = { "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.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 512db468d9..a3a2a05ed0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2311,6 +2311,7 @@ export const fr: Record = { "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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 94d4218a55..9b5de33c86 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2345,6 +2345,7 @@ export const ja: Record = { "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.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1585516ac5..9fc2969c3f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2346,6 +2346,7 @@ export const ko: Record = { "connection.discovering": "로컬 및 공유 대상을 확인하는 중…", "connection.machineUnavailable": "로컬 머신 연결을 사용할 수 없습니다. 공유 요청을 로컬로 우회하지 않았습니다.", "connection.disconnect": "허브 연결 해제", + "connection.disconnectConfirm": "이 머신의 허브 연결을 해제하고 독립 실행 모드로 다시 시작할까요?", "connection.pairing.title": "이 대시보드를 허브에 연결", "connection.pairing.body": "허브에서 만든 일회용 페어링 코드를 붙여 넣으세요.", "connection.pairing.relayWarning": "이 코드는 고정 허브 릴레이로 교환됩니다. 릴레이 목적지는 다른 호스트로 바꿀 수 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8e09703701..f5aa05bb69 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2347,6 +2347,7 @@ export const ru: Record = { "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.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 92aec70d7c..82b0a6c575 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2347,6 +2347,7 @@ export const tr: Record = { "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.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cb647d72af..e478942b46 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2309,6 +2309,7 @@ export const zhTW: Record = { "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.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f25ea6dd68..2fe77941a1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2345,6 +2345,7 @@ export const zh: Record = { "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.", diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index 7f06312a77..4e3ba89426 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -56,7 +56,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec if (tabRefs.current === null) tabRefs.current = new Map(); useEffect(() => { - if (!connected) { setMachineClients([]); return; } + if (!connected) return; const controller = new AbortController(); void fetch(`${machineApiBase}/api/machine/clients`, { signal: controller.signal }) .then(response => response.ok ? response.json() : null) diff --git a/gui/src/pages/Startup.tsx b/gui/src/pages/Startup.tsx index aa11a994ef..c9e5ef2aaf 100644 --- a/gui/src/pages/Startup.tsx +++ b/gui/src/pages/Startup.tsx @@ -93,7 +93,7 @@ export default function Startup({ apiBase, machineApiBase = apiBase, connected = const [machineBusy, setMachineBusy] = useState(false); useEffect(() => { - if (!connected) { setMachineShim(null); return; } + if (!connected) return; const controller = new AbortController(); void fetch(`${machineApiBase}/api/machine/shim`, { signal: controller.signal }) .then(response => response.ok ? response.json() : null) diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index 4d373cfc7a..af263228b4 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -62,6 +62,7 @@ test("App mounts the relay pairing form and installs only the returned shared se document.body.append(container); const { LanguageProvider } = await import("../src/i18n/provider"); 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 { diff --git a/src/client/hub-relay.ts b/src/client/hub-relay.ts index e4cde4dbb0..57d6da4cc1 100644 --- a/src/client/hub-relay.ts +++ b/src/client/hub-relay.ts @@ -145,7 +145,9 @@ export async function relayHubManagementRequest( const headers = filteredHeaders(stripped, REQUEST_HEADERS); if (!headersWithinLimit(headers)) return relayError(431, "hub relay request headers too large"); const browserOrigin = canonicalOrigin(target.browserOrigin); - if (!browserOrigin || headers.get("origin") !== 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"); } diff --git a/src/client/machine-auth.ts b/src/client/machine-auth.ts index 2b4dd3d6db..f2aad27499 100644 --- a/src/client/machine-auth.ts +++ b/src/client/machine-auth.ts @@ -31,7 +31,7 @@ function machinePrincipalRequest(req: Request): Request { headers.set("Origin", browserOrigin); } if (csrf) headers.set("x-opencodex-csrf-token", csrf); - return new Request(req, { headers }); + return new Request(req.url, { method: req.method, headers, signal: req.signal }); } export function requireMachineAuth( diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index da54d0d70f..476c2e70a4 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import type { Server } from "bun"; import { loadConfig } from "../config"; import { browserSecurityHeaders } from "../server/auth-cors"; -import { serveGuiFile, serveSessionBootstrap, rootFallbackPayload } from "../server/gui-static"; +import { serveGuiFile, serveSessionBootstrap } from "../server/gui-static"; import { initializeManagementAuthState, issueGuiSession, @@ -14,7 +14,7 @@ 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 { requireMachineAuth } from "./machine-auth"; +import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth"; import { relayHubManagementRequest } from "./hub-relay"; const VERSION = (() => { @@ -52,8 +52,7 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea if (req.method !== "GET" || path.startsWith("/api/") || path.startsWith("/v1/")) return false; return GUI_SPA_PATHS.has(path) || path.startsWith("/integrations/") - || path.startsWith("/assets/") - && /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path); + || /\.(?:css|gif|ico|jpe?g|js|json|map|png|svg|webp|woff2?)$/i.test(path); } export function startMachineListener( @@ -86,10 +85,10 @@ export function startMachineListener( 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", pid: process.pid, port: server.port }); + 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", pid: process.pid, port: server.port, protocolVersion: 1 }); + 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); @@ -99,7 +98,7 @@ export function startMachineListener( const suffix = `${url.pathname.slice(prefix.length)}${url.search}`; const response = await relayHubManagementRequest(req, suffix, { managementUrl: connection.managementUrl, - browserOrigin: req.headers.get("Origin") ?? "", + 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"; @@ -122,7 +121,14 @@ export function startMachineListener( const gui = serveGuiFile(url.pathname, undefined, session ?? undefined); if (gui) return gui; if (url.pathname === "/") { - return Response.json(rootFallbackPayload(), { headers: browserSecurityHeaders() }); + 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); }, From e3f6cf8a217426d6d07d797861a37029f392bc42 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 03:55:43 +0900 Subject: [PATCH 06/11] fix(two-plane): repair remote verification failures --- src/client/hub-relay.ts | 15 ++++++++++----- src/client/runtime.ts | 17 ++++++++--------- src/usage/summary.ts | 38 ++++++++++++++++++++++++++++--------- tests/usage-summary.test.ts | 8 ++++---- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/client/hub-relay.ts b/src/client/hub-relay.ts index 57d6da4cc1..ae75862f8e 100644 --- a/src/client/hub-relay.ts +++ b/src/client/hub-relay.ts @@ -76,14 +76,18 @@ function relayDestination(suffix: string, target: HubRelayTarget, method: string return destination; } -async function boundedBody(stream: ReadableStream | null, declared: string | null, limit: number): Promise { +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[] = []; + const chunks: Uint8Array[] = []; let length = 0; try { while (true) { @@ -96,7 +100,8 @@ async function boundedBody(stream: ReadableStream | null, declared: } finally { reader.releaseLock(); } - const body = new Uint8Array(length); + // 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); @@ -132,7 +137,7 @@ export async function relayHubManagementRequest( const destination = relayDestination(suffix, target, method); if (!destination) return relayError(404, "hub relay path refused"); - let body: Uint8Array | null; + let body: Uint8Array | null; try { body = method === "GET" || method === "HEAD" ? null @@ -175,7 +180,7 @@ export async function relayHubManagementRequest( return relayError(502, "hub relay redirect refused"); } - let responseBody: Uint8Array | null; + let responseBody: Uint8Array | null; const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS); if (!headersWithinLimit(responseHeaders)) { try { await upstream.body?.cancel(); } catch { /* best effort */ } diff --git a/src/client/runtime.ts b/src/client/runtime.ts index b9b302c02e..03f0be2cba 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -45,19 +45,18 @@ export async function startClientRuntime( 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 = preferred === 0 - ? 0 - : await findAvailablePort(preferred, "127.0.0.1", { - preferRetryMs: options.port === undefined ? 750 : 5_000, - preferRetryIntervalMs: 50, - allowEphemeralFallback: options.port === undefined, - }); + 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 = server.port; + activePort = boundPort; installCrashGuards(); writePid(process.pid); - writeRuntimePort({ pid: process.pid, port: server.port, hostname: "127.0.0.1" }); + writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" }); let shuttingDown = false; const shutdown = () => { diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 58560c3552..b37aac8533 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -1166,23 +1166,43 @@ export function projectUsageSummary( 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; }; - // The apiKeyId filter drops whole ENTRIES, because a key owns the entry rather than any - // individual attempt within it. Provider and model filters below narrow to matching - // ATTRIBUTIONS instead: keeping a whole combo entry because one attempt matched drags the - // other attempts' tokens and cost into the filtered totals, so a two-attempt combo - // filtered to its cheap model would report the expensive model's spend too. - const source = apiKeyId === null - ? entries ?? [] - : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId); + // 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); diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 3811615a08..8917e05f6a 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -402,9 +402,9 @@ describe("projectUsageSummary", () => { 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: "account-a" }), - entry({ ts: at + 1, requestId: "key-a-anthropic", apiKeyId: "Key-A", provider: "anthropic", model: "claude-opus", usageStatus: "reported", usage: priced, accountLogLabel: "account-b" }), - entry({ ts: at + 2, requestId: "key-b", apiKeyId: "key-a", provider: "openai", model: "gpt-5.5", usageStatus: "reported", usage: priced, accountLogLabel: "account-c" }), + 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); @@ -414,7 +414,7 @@ describe("projectUsageSummary", () => { 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(["account-a", "account-b"]); + expect(byKey.accounts.map(row => row.accountLogLabel).sort()).toEqual(["main", "pabc123"]); const combined = projectUsageSummary(summary, { apiKeyId: "Key-A", From a3fe759b391cd6bb37d8e098268d745b0b3e92a9 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:08:30 +0900 Subject: [PATCH 07/11] fix(two-plane): unblock client start and GUI verification --- gui/src/i18n/fr.ts | 52 +++++++++++++-------------- gui/src/i18n/zh-TW.ts | 52 +++++++++++++-------------- gui/tests/api-auth-memory.test.ts | 4 ++- gui/tests/claude-toggle-race.test.tsx | 1 + gui/tests/connect-pairing.test.ts | 14 +++++--- src/cli/dispatch.ts | 4 --- tests/cli-start-journal-order.test.ts | 11 ++++-- 7 files changed, 74 insertions(+), 64 deletions(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a3a2a05ed0..e06519c2ee 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2308,30 +2308,30 @@ export const fr: Record = { "models.aliasAuto": "auto", "models.aliasUser": "utilisateur", "models.aliasStale": "obsolète", - "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.", + "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/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e478942b46..6974aaabc6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2306,30 +2306,30 @@ export const zhTW: 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.", + "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/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 9a3ddf4fab..6483fbcaf5 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -29,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(); }); diff --git a/gui/tests/claude-toggle-race.test.tsx b/gui/tests/claude-toggle-race.test.tsx index b66f5fd94a..f1b74cd75c 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); diff --git a/gui/tests/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index af263228b4..07c79b020e 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -5,7 +5,7 @@ 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/#dashboard" }); + const win = new Window({ url: "http://localhost/#usage" }); Object.defineProperties(globalThis, { window: { configurable: true, value: win }, document: { configurable: true, value: win.document }, @@ -51,6 +51,11 @@ test("App mounts the relay pairing form and installs only the returned shared se 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, { @@ -74,8 +79,7 @@ test("App mounts the relay pairing form and installs only the returned shared se } const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); - input.dispatchEvent(new win.Event("input", { bubbles: true })); - input.dispatchEvent(new win.Event("change", { bubbles: true })); + 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; @@ -91,7 +95,7 @@ test("App mounts the relay pairing form and installs only the returned shared se await act(async () => { root.unmount(); }); container.remove(); win.close(); - for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); } }); @@ -137,6 +141,6 @@ test("a refused pairing renders an accessible error without clearing the pasted await act(async () => { root.unmount(); }); container.remove(); win.close(); - for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: previous[key] }); } }); 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/tests/cli-start-journal-order.test.ts b/tests/cli-start-journal-order.test.ts index c8ac68e9d8..712af88ce4 100644 --- a/tests/cli-start-journal-order.test.ts +++ b/tests/cli-start-journal-order.test.ts @@ -192,8 +192,15 @@ describe("start and ensure journal ownership (#1230)", () => { }); children.push(child); const runtimePath = join(fx.ocxHome, "runtime-port.json"); - const runtime = await waitFor(() => { - if (!existsSync(runtimePath)) return null; + 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; From 61710eff04cea27e743352b09a3f328e5a1a553a Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 04:14:48 +0900 Subject: [PATCH 08/11] docs(devlog): phase-4 gui screenshot evidence --- .../assets/gui-p4-dashboard.png | Bin 0 -> 94814 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devlog/_plan/260827_remote_hub/assets/gui-p4-dashboard.png 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 0000000000000000000000000000000000000000..72523a8f1a9e2d80969238ed63ccabc471f7d2dc GIT binary patch literal 94814 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 Date: Tue, 1 Sep 2026 17:35:55 +0900 Subject: [PATCH 09/11] fix(two-plane): declare the machine plane, enable relay, and finish the D1/D2 client side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects on this phase, plus the client half of two contract changes the earlier phases made on the server side. /api/machine/* is declared. The seven routes this phase adds were absent from the headless parity table, so tests/cli-headless-parity.test.ts failed on undeclared endpoints. They are not undocumented: status/clients mirror `ocx connect status`, sync mirrors `ocx sync`, shim mirrors the client integration commands, and disconnect mirrors `ocx disconnect`. hub-relay is the transport those commands select with --management-transport relay rather than a verb of its own. Declared as one prefix with that mapping written down. Relay actually works now. connectClient() threw "relay management transport is not available before Remote Hub Phase 4" — but this IS phase 4, and the machine listener plus hub-relay both land here. A documented option that always threw was worse than an undocumented one. A supervised client comes back after disconnect. scheduleStandaloneRecycle() skipped its own respawn when OCX_SERVICE=1, correctly leaving the restart to the supervisor, then exited 0. The real supervisor configs are failure-only (systemd Restart=on-failure, WinSW onfailure, the Task Scheduler ERRORLEVEL loop), so a clean exit reads as "finished" and nothing restarts — the client stayed down until someone noticed. Now exits 1 under supervision, the same policy the dashboard recycle already uses. launchd KeepAlive was fine either way. D1 client side: --allow-insecure-http is removed from the CLI, the connect options, and the hub client. The hub refuses plaintext pairing outright now, so keeping the flag would only spend a single-use grant against a certain rejection. The client checks the same rule locally and refuses before sending. D2 client side: the catalog fetch is unconditional. /v1/catalog emits no validator, so If-None-Match had nothing to match and connect's "initial hub catalog did not include a fresh ETag" check would have failed every connection. The stored catalogEtag becomes catalogFingerprint — our own hash of the bytes we wrote. That value was never a cache concern: it answers "is the file on disk still ours" before disconnect removes it, which needs no server participation. usage/summary.ts keeps dev's per-attribution filtering and this phase's per-key entry slice; the comment now says which filter operates at which level, because they are deliberately different. --- src/cli/connect.ts | 4 +-- src/cli/registry.ts | 2 +- src/client/connect.ts | 53 ++++++++++++++----------------- src/client/hub-client.ts | 46 +++++++++++++++++++++------ src/client/runtime.ts | 19 ++++++++++- src/config.ts | 2 +- src/types/config.ts | 9 +++++- tests/cli-headless-parity.test.ts | 7 ++++ tests/client-connect.test.ts | 28 ++++++++++------ tests/config.test.ts | 2 +- 10 files changed, 117 insertions(+), 55 deletions(-) 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/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/runtime.ts b/src/client/runtime.ts index 03f0be2cba..f8eb85920a 100644 --- a/src/client/runtime.ts +++ b/src/client/runtime.ts @@ -24,7 +24,24 @@ export function scheduleStandaloneRecycle(): void { const port = activePort; try { activeServer?.stop(true); } catch { /* best effort */ } cleanup(); - if (process.env.OCX_SERVICE !== "1" && port) { + // 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", 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/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/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/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/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, From 16ddd3eb5fb45251efeb6781b2ace4456aca208c Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 18:09:03 +0900 Subject: [PATCH 10/11] fix(two-plane): authenticate the relayed pairing exchange and isolate its test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relayed pairing request went out unauthenticated. submitConnectPairing took `fetchImpl: typeof fetch = fetch`, and a default parameter binds the global as it was when the module was evaluated — the unwrapped original, not the wrapper installApiAuthFetch puts on window.fetch. The relay needs the machine-session headers that wrapper attaches, so the hub refused the exchange. Resolved at call time now. The transport also moves to its own module. One file exported both a transport function and a component, which react-refresh/only-export-components flags for good reason; the previous shape carried an eslint-disable instead. The two have no reason to share a file: the transport is testable without React and the form has no logic beyond calling it. tests/connect-pairing.test.ts passed alone and failed in the full GUI run. 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 test now binds the wrapper to its own window before mounting, and claude-toggle-race.test.tsx clears the install latch in afterEach alongside the window it closes. Both are test isolation rather than product behavior. --- gui/src/connect-pairing-transport.ts | 38 +++++++++++++++++++++++++++ gui/src/connect-pairing.ts | 23 +--------------- gui/tests/claude-toggle-race.test.tsx | 8 ++++++ gui/tests/connect-pairing.test.ts | 13 +++++++++ 4 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 gui/src/connect-pairing-transport.ts 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 index 6c1aa5520a..00e48abd7a 100644 --- a/gui/src/connect-pairing.ts +++ b/gui/src/connect-pairing.ts @@ -1,28 +1,7 @@ -/* eslint-disable react-refresh/only-export-components -- pairing transport and its form share one session-install boundary */ import { createElement, useState, type ChangeEvent, type FormEvent } from "react"; -import { installApiSessionFromHtml } from "./api"; import type { ApiTarget } from "./api-targets"; import { useT } from "./i18n/shared"; - -const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; - -export async function submitConnectPairing( - target: ApiTarget, - grant: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const code = grant.trim(); - if (!PAIRING_CODE.test(code)) throw new Error("pairing_code_invalid"); - const response = await fetchImpl(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; -} +import { submitConnectPairing } from "./connect-pairing-transport"; export function ConnectPairingForm({ target, diff --git a/gui/tests/claude-toggle-race.test.tsx b/gui/tests/claude-toggle-race.test.tsx index f1b74cd75c..91bd6d824f 100644 --- a/gui/tests/claude-toggle-race.test.tsx +++ b/gui/tests/claude-toggle-race.test.tsx @@ -127,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/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index 07c79b020e..4a3f2e22ef 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -66,6 +66,19 @@ test("App mounts the relay pairing form and installs only the returned shared se 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"); From 95639f028bc6f462118d734e2e3d1cfd5fbc634e Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 19:36:58 +0900 Subject: [PATCH 11/11] fix(two-plane): a standalone install neither probes nor announces the machine plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a user who never enabled remote hub was paying for. Every dashboard load fired GET /api/machine/status. Discovery ran unconditionally and inferred standalone FROM the resulting 404, so the browser announced the feature's existence on every paint of a plain install. The server already injects session meta into the served document, so it now states the runtime role there too and the client reads it instead of asking. A missing tag reads as standalone, which covers an older server, a separately hosted GUI, and the Vite dev server — all of which should make no remote-hub request. The role meta is emitted independently of the session block. A standalone install never issues a GUI session, so tying the role to session issuance would have left exactly the case that needs it with nothing to read. The page body was gated behind targetsSettled, so a standalone user saw "Discovering local and shared targets…" before their own dashboard. Standalone now starts settled: there is nothing to discover, so there is nothing to wait for. A failed discovery replaced the entire body with a machine-plane error. A slow or restarting proxy cost a standalone user their dashboard over a plane they never turned on. It is a banner now; the requests that actually need the machine plane still report their own failures. Regressions are driven red against the previous behavior: standalone discovery makes zero fetches across null/standalone/hub roles, and a client role still discovers, so the tag narrows who asks rather than removing discovery. --- gui/src/App.tsx | 18 +++++++++--- gui/src/api-targets.ts | 33 +++++++++++++++++++++ gui/tests/api-targets.test.ts | 48 +++++++++++++++++++++++++++++++ gui/tests/connect-pairing.test.ts | 4 +++ src/server/gui-static.ts | 26 +++++++++++++---- src/server/index.ts | 7 ++++- 6 files changed, 126 insertions(+), 10 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 014aec7e46..d6167ab2cc 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -16,7 +16,7 @@ import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconH import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; import { configureApiTargets, hasApiSession, installApiAuthFetch, installApiSessionFromHtml } from "./api"; -import { apiBaseForPlane, discoverApiTargets, standaloneApiTargets, type ApiTargets } from "./api-targets"; +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"; @@ -105,7 +105,10 @@ export default function App() { const { locale, setLocale } = useI18n(); const t = useT(); const [targets, setTargets] = useState(INITIAL_TARGETS); - const [targetsSettled, setTargetsSettled] = useState(false); + // 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")); @@ -366,10 +369,17 @@ export default function App() { > {!targetsSettled ? (
{t("connection.discovering")}
- ) : targetError ? ( -
{t("connection.machineUnavailable")}
) : ( <> + {/* + 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)} /> )} diff --git a/gui/src/api-targets.ts b/gui/src/api-targets.ts index 000a32a058..7a1a1d17d6 100644 --- a/gui/src/api-targets.ts +++ b/gui/src/api-targets.ts @@ -1,6 +1,29 @@ 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; @@ -117,6 +140,16 @@ export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string { 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" }); diff --git a/gui/tests/api-targets.test.ts b/gui/tests/api-targets.test.ts index b3f9f33e74..3aacf344b8 100644 --- a/gui/tests/api-targets.test.ts +++ b/gui/tests/api-targets.test.ts @@ -11,8 +11,23 @@ import { 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, @@ -28,14 +43,19 @@ const status = (transport: "direct" | "relay"): MachineStatusV1 => ({ 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(); }); @@ -60,7 +80,35 @@ describe("two-plane API targets", () => { }); 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/connect-pairing.test.ts b/gui/tests/connect-pairing.test.ts index 4a3f2e22ef..ae68a1d1f7 100644 --- a/gui/tests/connect-pairing.test.ts +++ b/gui/tests/connect-pairing.test.ts @@ -22,6 +22,10 @@ test("App mounts the relay pairing form and installs only the returned shared se ["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; 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