From adac530f3189a623ae2d8eae43fd0f9a7ae19c23 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 2 Sep 2026 00:56:43 +0900 Subject: [PATCH] fix(client): tell the dashboard it is a client The machine listener served the GUI without the runtime-role meta tag, so a connected client rendered as a plain standalone install. The tag is not decoration. gui/src/api-targets.ts reads it in isConnectedRuntime(), and discoverApiTargets() returns standalone targets immediately when it is anything other than "client" -- deliberately, so a user who never enabled remote hub issues no request to a remote-hub endpoint. The consequence on a real client was that discovery never queried /api/machine/status: no hub usage scope on Usage, no "this machine" panel on Startup, no connected-client list on Integrations, and no pairing form. src/server/index.ts already passes config.runtimeRole on the same call. The listener only ever serves a connected client, so the role is a constant here rather than a config read. Verified by hand before and after: with the tag absent the served document has only the session meta and the dashboard shows the standalone layout; with it present the two-plane UI appears, including "Disconnect from hub" and the pairing form. The regression test asserts the call carries the role. It reads the source rather than the HTTP response because the listener falls through to a JSON payload when gui/dist is absent, which would make an HTTP-level assertion pass vacuously in a checkout with no GUI build. A second test covers the document itself through serveGuiFile with a temporary dist. Both were driven red against the unfixed call. --- src/client/machine-listener.ts | 9 +++++- tests/client-machine-listener.test.ts | 41 ++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/client/machine-listener.ts b/src/client/machine-listener.ts index 476c2e70a4..b7e54032b6 100644 --- a/src/client/machine-listener.ts +++ b/src/client/machine-listener.ts @@ -118,7 +118,14 @@ export function startMachineListener( ? issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false }) : null; if (url.pathname === "/opencodex-session" && session) return serveSessionBootstrap(session); - const gui = serveGuiFile(url.pathname, undefined, session ?? undefined); + // State the role, exactly as the standalone/hub server does (src/server/index.ts). + // The GUI decides whether a machine plane exists from this tag alone + // (gui/src/api-targets.ts `isConnectedRuntime`): without it `discoverApiTargets` + // returns standalone targets and never queries /api/machine/status, so a connected + // client renders as a plain install — no hub usage scope, no "this machine" panel, + // no connected-client list. This listener only ever serves a connected client, so + // the role is a constant here rather than a config read. + const gui = serveGuiFile(url.pathname, undefined, session ?? undefined, "client"); if (gui) return gui; if (url.pathname === "/") { return Response.json({ diff --git a/tests/client-machine-listener.test.ts b/tests/client-machine-listener.test.ts index 511961c8bd..d90e9c7a05 100644 --- a/tests/client-machine-listener.test.ts +++ b/tests/client-machine-listener.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, 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 { serveGuiFile } from "../src/server/gui-static"; import type { OcxClientConnectionConfig } from "../src/types"; import type { ManagementAuthState } from "../src/server/management-auth"; @@ -155,3 +156,41 @@ describe("client machine listener", () => { expect(() => startMachineListener(0, { managementAuthState: authState() })).toThrow(/requires connected client state/); }); }); + +describe("the served document states the client role", () => { + // The GUI decides whether a machine plane exists from this tag alone + // (gui/src/api-targets.ts `isConnectedRuntime` / `discoverApiTargets`). A missing tag is + // not cosmetic: discovery returns standalone targets immediately and never queries + // /api/machine/status, so a connected client renders as a plain install — no hub usage + // scope, no "this machine" panel, no connected-client list. + // + // Asserted against `serveGuiFile` directly rather than over HTTP, because the listener + // falls through to a JSON payload when `gui/dist` is absent, and a checkout without a + // GUI build would make an HTTP-level assertion pass vacuously. + test("the client dashboard document carries the role tag", () => { + const dist = mkdtempSync(join(tmpdir(), "ocx-gui-dist-")); + try { + writeFileSync(join(dist, "index.html"), ""); + const response = serveGuiFile("/", dist, undefined, "client"); + expect(response).not.toBeNull(); + return response!.text().then(html => { + expect(meta(html, "opencodex-runtime-role")).toBe("client"); + }); + } finally { + rmSync(dist, { recursive: true, force: true }); + } + }); + + test("the listener asks for the client role rather than leaving it undefined", () => { + // Source-level, deliberately: the call is what carries the role, and the HTTP path + // cannot show it in a checkout with no GUI build. Reading the file keeps the + // assertion honest in both cases. + const source = readFileSync( + join(import.meta.dir, "..", "src", "client", "machine-listener.ts"), + "utf8", + ); + const call = /serveGuiFile\(([^)]*)\)/.exec(source); + expect(call, "machine-listener no longer calls serveGuiFile").not.toBeNull(); + expect(call![1]).toContain('"client"'); + }); +});