fix: prioritize v02 workspace first screen

This commit is contained in:
Codex Agent
2026-06-05 15:55:18 +08:00
parent 00bccbb61a
commit 775c986a72
8 changed files with 291 additions and 37 deletions
+2 -1
View File
@@ -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",
@@ -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,
+103 -2
View File
@@ -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) {
@@ -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) => {
+25 -13
View File
@@ -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}
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => 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" ? <PerformanceView /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "access" ? <AccessView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "performance" ? <DeferredRouteView><PerformanceView /></DeferredRouteView> : null}
{route === "skills" ? <DeferredRouteView><SkillsView /></DeferredRouteView> : null}
{route === "access" ? <DeferredRouteView><AccessView /></DeferredRouteView> : null}
{route === "gate" ? <DeferredRouteView><GateView /></DeferredRouteView> : null}
{route === "help" ? <HelpView help={help} /> : null}
{route === "settings" ? <SettingsView providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onLogout={auth.logout} /> : null}
{route === "settings" ? <DeferredRouteView><SettingsView providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onLogout={auth.logout} /></DeferredRouteView> : null}
{workspaceRoute ? <CommandBar key={store.activeConversationId ?? "default"} disabled={store.composer.disabled} interactionDisabled={workspaceShellPendingAuth} submitMode={store.composer.submitMode} targetTraceId={store.composer.targetTraceId} providerProfile={store.state.providerProfile} onProviderProfile={store.setProviderProfile} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} onPickDraft={pickDraft} /> : null}
</section>
{workspaceRoute ? (
@@ -278,6 +278,18 @@ function AuthLoadingShell(): ReactElement {
);
}
function DeferredRouteView({ children }: { children: ReactNode }): ReactElement {
return <Suspense fallback={<RouteLoadingView />}>{children}</Suspense>;
}
function RouteLoadingView(): ReactElement {
return (
<section className="view route-loading-view" id="route-loading" aria-live="polite" aria-busy="true">
<div className="workspace-panel route-loading-panel"><div className="panel-title-row"><div><p className="eyebrow">HWLAB</p><h2></h2></div><span className="state-tag tone-source"></span></div></div>
</section>
);
}
function HelpView({ help }: { help: string }): ReactElement {
return (
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
+4 -4
View File
@@ -23,7 +23,9 @@ export function useAuth(): UseAuthResult {
useEffect(() => {
let canceled = false;
async function run(): Promise<void> {
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) {
@@ -205,6 +205,7 @@ function errorMessage(payload: unknown, fallback: string): string {
export const api = {
session: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/session", { timeoutName: "auth session" }),
authBootstrap: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }),
login: (username: string, password: string): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
+71 -17
View File
@@ -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<number | null>(null);
const conversationListTimerRef = useRef<number | null>(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) {