diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs
index e87186b8..d6fed256 100644
--- a/internal/dev-entrypoint/cloud-web-routes.mjs
+++ b/internal/dev-entrypoint/cloud-web-routes.mjs
@@ -1,5 +1,5 @@
const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]);
-const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session"]);
+const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap"]);
const POST_PROXY_ROUTES = new Set([
"/auth/login",
"/auth/logout",
@@ -15,6 +15,7 @@ const POST_PROXY_ROUTES = new Set([
]);
const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/session",
+ "GET /auth/bootstrap",
"POST /auth/login",
"POST /auth/logout",
"POST /v1/agent/chat",
diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs
index f699f2f3..b93f16ab 100644
--- a/internal/dev-entrypoint/cloud-web-routes.test.mjs
+++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs
@@ -54,6 +54,15 @@ test("cloud web proxies public WebUI performance telemetry to cloud-api", () =>
});
});
+test("cloud web exposes auth bootstrap as a public auth route", () => {
+ assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/bootstrap"), {
+ proxy: true,
+ authRequired: false,
+ publicRoute: true,
+ routeKey: "GET /auth/bootstrap"
+ });
+});
+
test("cloud web proxies authenticated API key management write routes", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/api-keys"), {
proxy: true,
diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs
index d5307a43..71b14a85 100644
--- a/internal/dev-entrypoint/cloud-web-runtime.mjs
+++ b/internal/dev-entrypoint/cloud-web-runtime.mjs
@@ -1,10 +1,10 @@
-import { createServer } from "node:http";
+import { createServer, request as httpRequest } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { gzip } from "node:zlib";
import { promisify } from "node:util";
-import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
+import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs";
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
const readOnlyRpcMethods = new Set([
@@ -15,6 +15,8 @@ const readOnlyRpcMethods = new Set([
]);
const gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
+const DEFAULT_AUTH_USERNAME = "admin";
+const DEFAULT_AUTH_PASSWORD = "hwlab2026";
export async function serveCloudWeb(options) {
const server = createCloudWebServer(options);
@@ -86,6 +88,10 @@ export function createCloudWebServer({
export async function handleCloudWebAuth(options) {
const { request, response, url } = options;
+ if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
+ await handleAuthBootstrap(options);
+ return true;
+ }
if (url.pathname === "/auth/session" && request.method === "GET") {
await proxyCloudApi(options);
return true;
@@ -103,6 +109,101 @@ export async function handleCloudWebAuth(options) {
return true;
}
+async function handleAuthBootstrap(options) {
+ const { request, response, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
+ if (!cloudApiBaseUrl) {
+ sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/bootstrap" });
+ return;
+ }
+
+ const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
+ if (session.ok && session.body?.authenticated === true) {
+ sendJsonWithForwardedCookies(response, session.statusCode, session.body, session.headers);
+ return;
+ }
+
+ const config = webAuthConfig();
+ if (config.mode !== "auto") {
+ sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers);
+ return;
+ }
+
+ const login = await requestCloudApiJson({
+ request,
+ method: "POST",
+ pathname: "/auth/login",
+ body: JSON.stringify({ username: config.username, password: config.password }),
+ cloudApiBaseUrl,
+ cloudApiProxyTimeoutMs
+ });
+ sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers);
+}
+
+function webAuthConfig() {
+ return {
+ mode: authConfigValue("HWLAB_CLOUD_WEB_AUTH_MODE", "auto"),
+ username: authConfigValue("HWLAB_CLOUD_WEB_AUTH_USERNAME", DEFAULT_AUTH_USERNAME),
+ password: authConfigValue("HWLAB_CLOUD_WEB_AUTH_PASSWORD", DEFAULT_AUTH_PASSWORD)
+ };
+}
+
+function authConfigValue(name, fallback) {
+ const value = process.env[name];
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
+}
+
+function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const target = new URL(pathname, cloudApiBaseUrl);
+ const proxyRequest = { method, headers: upstreamRequestHeaders({ headers: request.headers }, body) };
+ const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
+ const chunks = [];
+ upstreamResponse.on("data", (chunk) => chunks.push(chunk));
+ upstreamResponse.on("end", () => {
+ if (settled) return;
+ settled = true;
+ const rawBody = Buffer.concat(chunks).toString("utf8");
+ let parsed = null;
+ try { parsed = rawBody ? JSON.parse(rawBody) : null; } catch { parsed = { error: "invalid_json", rawBody: rawBody.slice(0, 200) }; }
+ resolve({ ok: (upstreamResponse.statusCode || 0) >= 200 && (upstreamResponse.statusCode || 0) < 300, statusCode: upstreamResponse.statusCode || 502, body: parsed, headers: upstreamResponse.headers });
+ });
+ upstreamResponse.on("error", (error) => {
+ if (settled) return;
+ settled = true;
+ resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
+ });
+ });
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ upstream.destroy();
+ resolve({ ok: false, statusCode: 504, body: { authenticated: false, error: "auth_bootstrap_timeout" }, headers: {} });
+ }, cloudApiProxyTimeoutMs);
+ upstream.on("error", (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ resolve({ ok: false, statusCode: 502, body: { authenticated: false, error: error.message }, headers: {} });
+ });
+ upstream.on("close", () => clearTimeout(timeout));
+ if (body) upstream.write(body);
+ upstream.end();
+ });
+}
+
+function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
+ const payload = JSON.stringify(body);
+ const setCookie = headers["set-cookie"];
+ const responseHeaders = {
+ "content-type": "application/json",
+ "content-length": Buffer.byteLength(payload)
+ };
+ if (setCookie) responseHeaders["set-cookie"] = setCookie;
+ response.writeHead(statusCode, responseHeaders);
+ response.end(payload);
+}
+
async function readRequestBody(request) {
let body = "";
for await (const chunk of request) {
diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs
index 96593138..419ef9c2 100644
--- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs
+++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs
@@ -47,6 +47,82 @@ test("cloud web auth requests are proxied once", async () => {
}
});
+test("cloud web auth bootstrap keeps an existing session without auto-login", async () => {
+ const upstreamRequests = [];
+ const upstream = createServer((request, response) => {
+ upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie });
+ request.resume();
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify({ authenticated: true, actor: { username: "alice" }, path: request.url }));
+ });
+ await listen(upstream);
+
+ const cloudWeb = createCloudWebServer({
+ serviceId: "hwlab-cloud-web",
+ cloudApiBaseUrl: serverUrl(upstream),
+ cloudApiProxyTimeoutMs: 1000,
+ healthPayload: () => ({ status: "ok" }),
+ sendJson(response, statusCode, body) {
+ const payload = JSON.stringify(body);
+ response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
+ response.end(payload);
+ }
+ });
+ await listen(cloudWeb);
+
+ try {
+ const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`, { headers: { cookie: "hwlab_session=alice" } });
+ assert.equal(response.status, 200);
+ assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "alice" }, path: "/auth/session" });
+ assert.deepEqual(upstreamRequests, [{ method: "GET", url: "/auth/session", cookie: "hwlab_session=alice" }]);
+ } finally {
+ await close(cloudWeb);
+ await close(upstream);
+ }
+});
+
+test("cloud web auth bootstrap auto-logins only after an unauthenticated session", async () => {
+ const upstreamRequests = [];
+ const upstream = createServer(async (request, response) => {
+ let body = "";
+ for await (const chunk of request) body += chunk;
+ upstreamRequests.push({ method: request.method, url: request.url, body });
+ response.writeHead(200, {
+ "content-type": "application/json",
+ ...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" } : {})
+ });
+ response.end(JSON.stringify(request.url === "/auth/login" ? { authenticated: true, actor: { username: "admin" } } : { authenticated: false }));
+ });
+ await listen(upstream);
+
+ const cloudWeb = createCloudWebServer({
+ serviceId: "hwlab-cloud-web",
+ cloudApiBaseUrl: serverUrl(upstream),
+ cloudApiProxyTimeoutMs: 1000,
+ healthPayload: () => ({ status: "ok" }),
+ sendJson(response, statusCode, body) {
+ const payload = JSON.stringify(body);
+ response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
+ response.end(payload);
+ }
+ });
+ await listen(cloudWeb);
+
+ try {
+ const response = await fetch(`${serverUrl(cloudWeb)}/auth/bootstrap`);
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("set-cookie"), "hwlab_session=session-auto; Path=/; HttpOnly");
+ assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "admin" } });
+ assert.deepEqual(upstreamRequests, [
+ { method: "GET", url: "/auth/session", body: "" },
+ { method: "POST", url: "/auth/login", body: JSON.stringify({ username: "admin", password: "hwlab2026" }) }
+ ]);
+ } finally {
+ await close(cloudWeb);
+ await close(upstream);
+ }
+});
+
test("cloud web proxies Admin Access write routes", async () => {
const upstreamRequests = [];
const upstream = createServer(async (request, response) => {
diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx
index 3e37d656..2e4f6c02 100644
--- a/web/hwlab-cloud-web/src/App.tsx
+++ b/web/hwlab-cloud-web/src/App.tsx
@@ -1,6 +1,5 @@
-import type { CSSProperties, ReactElement } from "react";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useCallback } from "react";
+import type { CSSProperties, ReactElement, ReactNode } from "react";
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "./hooks/useAuth";
import { useSidebarResize } from "./hooks/useSidebarResize";
@@ -12,18 +11,19 @@ import { recordDraft } from "./components/command-bar/DraftsList";
import { ConversationPanel } from "./components/conversation/ConversationPanel";
import { DevicePodSidebar } from "./components/device-pod/DevicePodSidebar";
import { ActivityRail } from "./components/layout/ActivityRail";
-import { AccessView } from "./components/access/AccessView";
import { SessionSidebar } from "./components/sessions/SessionSidebar";
-import { SettingsView } from "./components/settings/SettingsView";
-import { SkillsView } from "./components/skills/SkillsView";
-import { GateView } from "./components/gate/GateView";
-import { PerformanceView } from "./components/performance/PerformanceView";
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
import { fetchText } from "./services/api/client";
import { installWebPerformanceTelemetry } from "./services/performance/rum";
import { firstNonEmptyString } from "./utils";
+const AccessView = lazy(() => import("./components/access/AccessView").then((module) => ({ default: module.AccessView })));
+const GateView = lazy(() => import("./components/gate/GateView").then((module) => ({ default: module.GateView })));
+const PerformanceView = lazy(() => import("./components/performance/PerformanceView").then((module) => ({ default: module.PerformanceView })));
+const SettingsView = lazy(() => import("./components/settings/SettingsView").then((module) => ({ default: module.SettingsView })));
+const SkillsView = lazy(() => import("./components/skills/SkillsView").then((module) => ({ default: module.SkillsView })));
+
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
const SESSION_SIDEBAR_DEFAULT_WIDTH = 330;
const SESSION_SIDEBAR_MIN_WIDTH = 180;
@@ -186,12 +186,12 @@ export function App(): ReactElement {
) : null}
{route === "workspace" ? copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
- {route === "performance" ? : null}
- {route === "skills" ? : null}
- {route === "access" ? : null}
- {route === "gate" ? : null}
+ {route === "performance" ? : null}
+ {route === "skills" ? : null}
+ {route === "access" ? : null}
+ {route === "gate" ? : null}
{route === "help" ? : null}
- {route === "settings" ? : null}
+ {route === "settings" ? : null}
{workspaceRoute ? : null}
{workspaceRoute ? (
@@ -278,6 +278,18 @@ function AuthLoadingShell(): ReactElement {
);
}
+function DeferredRouteView({ children }: { children: ReactNode }): ReactElement {
+ return }>{children};
+}
+
+function RouteLoadingView(): ReactElement {
+ return (
+
+ );
+}
+
function HelpView({ help }: { help: string }): ReactElement {
return (
diff --git a/web/hwlab-cloud-web/src/hooks/useAuth.ts b/web/hwlab-cloud-web/src/hooks/useAuth.ts
index 65388f0e..610d831a 100644
--- a/web/hwlab-cloud-web/src/hooks/useAuth.ts
+++ b/web/hwlab-cloud-web/src/hooks/useAuth.ts
@@ -23,7 +23,9 @@ export function useAuth(): UseAuthResult {
useEffect(() => {
let canceled = false;
async function run(): Promise {
- const current = await api.session();
+ const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
+ const mode = config?.mode ?? "auto";
+ const current = mode === "auto" ? await api.authBootstrap() : await api.session();
if (canceled) return;
if (current.ok && current.data?.authenticated === true) {
const next = sessionFromPayload(current.data);
@@ -31,11 +33,9 @@ export function useAuth(): UseAuthResult {
setAuthState("authenticated");
return;
}
- const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
const username = nonEmptyString(config?.username) ?? DEFAULT_USERNAME;
const password = nonEmptyString(config?.password) ?? DEFAULT_PASSWORD;
- const mode = config?.mode ?? "auto";
- if (mode === "auto") {
+ if (mode === "auto" && current.status === 404) {
const autoLogin = await api.login(username, password);
if (canceled) return;
if (autoLogin.ok && autoLogin.data?.authenticated === true) {
diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts
index 4f1864c9..2b21122a 100644
--- a/web/hwlab-cloud-web/src/services/api/client.ts
+++ b/web/hwlab-cloud-web/src/services/api/client.ts
@@ -205,6 +205,7 @@ function errorMessage(payload: unknown, fallback: string): string {
export const api = {
session: (): Promise> => fetchJson("/auth/session", { timeoutName: "auth session" }),
+ authBootstrap: (): Promise> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }),
login: (username: string, password: string): Promise> => fetchJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts
index 4ace2b31..409ae23d 100644
--- a/web/hwlab-cloud-web/src/state/workbench.ts
+++ b/web/hwlab-cloud-web/src/state/workbench.ts
@@ -49,6 +49,8 @@ import {
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
+const CONVERSATION_LIST_DELAY_MS = 1_000;
+const FIRST_SCREEN_LIVE_DELAY_MS = 1_400;
const FIRST_SCREEN_EVENT_DELAY_MS = 1_600;
const initialState: WorkbenchState = {
workspace: null,
@@ -104,6 +106,11 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const selectedDevicePodIdRef = useRef(initialState.selectedDevicePodId);
const liveRequestSeqRef = useRef(0);
const deferredEventTimerRef = useRef(null);
+ const conversationListTimerRef = useRef(null);
+ const hydrateInFlightRef = useRef(false);
+ const normalHydrateQueuedRef = useRef(false);
+ const workspaceLoadedRef = useRef(false);
+ const enabledRef = useRef(enabled);
const devicePodEventsRef = useRef<{ devicePodId: string; events: LiveSurface["devicePodEvents"] } | null>(null);
// Single shared activity clock so submit, trace polling, and user-typing
// all reset the same inactivity window. Kept in a ref (not state) so
@@ -138,28 +145,65 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const composer = useMemo(() => composerFromState(state, activeConversationId), [activeConversationId, state]);
+ useEffect(() => {
+ enabledRef.current = enabled;
+ }, [enabled]);
+
useEffect(() => {
selectedDevicePodIdRef.current = state.selectedDevicePodId;
}, [state.selectedDevicePodId]);
- const hydrate = useCallback(async () => {
- dispatch({ type: "hydrate:start" });
- const conversationsPromise = api.conversations(WORKBENCH_PROJECT_ID);
- const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
- if (!workspaceResult.ok) {
- dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
- return;
- }
- const workspace = workspaceResult.data?.workspace ?? null;
- dispatch({
- type: "hydrate:done",
- workspace,
- messages: messagesFromWorkspace(workspace)
- });
- const conversationsResult = await conversationsPromise;
+ const loadConversations = useCallback(async () => {
+ const conversationsResult = await api.conversations(WORKBENCH_PROJECT_ID);
if (conversationsResult.ok) dispatch({ type: "conversation:list", conversations: conversationsResult.data?.conversations ?? [] });
}, []);
+ const clearConversationListTimer = useCallback(() => {
+ if (conversationListTimerRef.current === null) return;
+ window.clearTimeout(conversationListTimerRef.current);
+ conversationListTimerRef.current = null;
+ }, []);
+
+ const scheduleConversationList = useCallback((delayMs = CONVERSATION_LIST_DELAY_MS) => {
+ clearConversationListTimer();
+ conversationListTimerRef.current = window.setTimeout(() => {
+ conversationListTimerRef.current = null;
+ void loadConversations();
+ }, delayMs);
+ }, [clearConversationListTimer, loadConversations]);
+
+ const hydrate = useCallback(async () => {
+ if (hydrateInFlightRef.current) {
+ normalHydrateQueuedRef.current = true;
+ return;
+ }
+ hydrateInFlightRef.current = true;
+ dispatch({ type: "hydrate:start" });
+ const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
+ try {
+ if (!workspaceResult.ok) {
+ workspaceLoadedRef.current = false;
+ dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
+ return;
+ }
+ const workspace = workspaceResult.data?.workspace ?? null;
+ workspaceLoadedRef.current = true;
+ dispatch({
+ type: "hydrate:done",
+ workspace,
+ messages: messagesFromWorkspace(workspace)
+ });
+ if (enabledRef.current) scheduleConversationList();
+ } finally {
+ hydrateInFlightRef.current = false;
+ if (normalHydrateQueuedRef.current) {
+ normalHydrateQueuedRef.current = false;
+ if (enabledRef.current && !workspaceLoadedRef.current) void hydrate();
+ else if (enabledRef.current) scheduleConversationList();
+ }
+ }
+ }, [scheduleConversationList]);
+
const refreshLive = useCallback(async (devicePodId?: string) => {
const requestSeq = ++liveRequestSeqRef.current;
const requestedDevicePodId = devicePodId ?? selectedDevicePodIdRef.current;
@@ -201,14 +245,24 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
useEffect(() => {
if (!enabled) return;
+ if (workspaceLoadedRef.current) {
+ scheduleConversationList();
+ return;
+ }
void hydrate();
- }, [enabled, hydrate]);
+ }, [enabled, hydrate, scheduleConversationList]);
+
+ useEffect(() => {
+ if (enabled) return;
+ clearConversationListTimer();
+ }, [enabled, clearConversationListTimer]);
useEffect(() => {
if (!enabled) return;
- void refreshLive();
+ const firstLiveTimer = window.setTimeout(() => void refreshLive(), FIRST_SCREEN_LIVE_DELAY_MS);
const timer = window.setInterval(() => void refreshLive(), 30_000);
return () => {
+ window.clearTimeout(firstLiveTimer);
window.clearInterval(timer);
liveRequestSeqRef.current += 1;
if (deferredEventTimerRef.current !== null) {