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 ( +
+

HWLAB

正在加载

准备中
+
+ ); +} + 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) {