fix: collapse v02 workspace first screen bootstrap

This commit is contained in:
Codex Agent
2026-06-05 16:23:20 +08:00
parent 7687a4306f
commit 20ced5245b
9 changed files with 225 additions and 15 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", "/auth/bootstrap"]);
const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap", "/auth/workspace-bootstrap"]);
const POST_PROXY_ROUTES = new Set([
"/auth/login",
"/auth/logout",
@@ -16,6 +16,7 @@ const POST_PROXY_ROUTES = new Set([
const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/session",
"GET /auth/bootstrap",
"GET /auth/workspace-bootstrap",
"POST /auth/login",
"POST /auth/logout",
"POST /v1/agent/chat",
@@ -61,6 +61,12 @@ test("cloud web exposes auth bootstrap as a public auth route", () => {
publicRoute: true,
routeKey: "GET /auth/bootstrap"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/workspace-bootstrap"), {
proxy: true,
authRequired: false,
publicRoute: true,
routeKey: "GET /auth/workspace-bootstrap"
});
});
test("cloud web proxies authenticated API key management write routes", () => {
+87 -9
View File
@@ -17,6 +17,7 @@ const gzipAsync = promisify(gzip);
const GZIP_MIN_BYTES = 1024;
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_WORKBENCH_PROJECT_ID = "prj_device_pod_workbench";
export async function serveCloudWeb(options) {
const server = createCloudWebServer(options);
@@ -88,6 +89,10 @@ export function createCloudWebServer({
export async function handleCloudWebAuth(options) {
const { request, response, url } = options;
if (url.pathname === "/auth/workspace-bootstrap" && request.method === "GET") {
await handleWorkspaceBootstrap(options);
return true;
}
if (url.pathname === "/auth/bootstrap" && request.method === "GET") {
await handleAuthBootstrap(options);
return true;
@@ -116,18 +121,50 @@ async function handleAuthBootstrap(options) {
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);
const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
sendJsonWithForwardedCookies(response, auth.statusCode || 502, auth.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, auth.headers);
}
async function handleWorkspaceBootstrap(options) {
const { request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options;
if (!cloudApiBaseUrl) {
sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/workspace-bootstrap" });
return;
}
const config = webAuthConfig();
if (config.mode !== "auto") {
sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers);
const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (auth.body?.authenticated !== true) {
sendJsonWithForwardedCookies(response, auth.statusCode || 200, auth.body ?? { authenticated: false }, auth.headers);
return;
}
const projectId = authConfigValueFromSearch(url.searchParams.get("projectId"), DEFAULT_WORKBENCH_PROJECT_ID);
const workspace = await requestCloudApiJson({
request,
method: "GET",
pathname: `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`,
headers: auth.cookieHeader ? { cookie: auth.cookieHeader } : {},
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
const body = {
...(auth.body ?? { authenticated: true }),
projectId,
workspace: workspace.ok ? workspace.body?.workspace ?? null : null,
workspaceStatus: workspace.statusCode || null,
workspaceError: workspace.ok ? null : workspace.body?.error ?? workspace.body?.message ?? null
};
sendJsonWithForwardedCookies(response, auth.statusCode || 200, body, auth.headers);
}
async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const config = webAuthConfig();
if (config.mode !== "auto" || hasCookieHeader(request.headers.cookie)) {
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (session.ok && session.body?.authenticated === true) return withCookieHeader(request, session);
if (config.mode !== "auto") return withCookieHeader(request, session);
}
const login = await requestCloudApiJson({
request,
method: "POST",
@@ -136,7 +173,7 @@ async function handleAuthBootstrap(options) {
cloudApiBaseUrl,
cloudApiProxyTimeoutMs
});
sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers);
return withCookieHeader(request, login);
}
function webAuthConfig() {
@@ -152,11 +189,15 @@ function authConfigValue(name, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
function authConfigValueFromSearch(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function requestCloudApiJson({ request, method, pathname, body = "", headers = {}, 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 proxyRequest = { method, headers: { ...upstreamRequestHeaders({ headers: request.headers }, body), ...headers } };
const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => {
const chunks = [];
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
@@ -192,6 +233,43 @@ function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBas
});
}
function withCookieHeader(request, result) {
return {
...result,
cookieHeader: cookieHeaderForUpstream(request.headers.cookie, result.headers?.["set-cookie"])
};
}
function cookieHeaderForUpstream(existingCookie, setCookie) {
const pairs = new Map();
for (const pair of cookiePairsFromHeader(existingCookie)) pairs.set(pair.name, pair.value);
for (const pair of cookiePairsFromSetCookie(setCookie)) pairs.set(pair.name, pair.value);
return Array.from(pairs.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
}
function cookiePairsFromHeader(header) {
if (!header) return [];
return String(header).split(";").map((part) => cookiePair(part)).filter(Boolean);
}
function hasCookieHeader(header) {
return cookiePairsFromHeader(header).length > 0;
}
function cookiePairsFromSetCookie(setCookie) {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((value) => cookiePair(String(value).split(";")[0])).filter(Boolean);
}
function cookiePair(value) {
const index = String(value).indexOf("=");
if (index <= 0) return null;
const name = String(value).slice(0, index).trim();
const cookieValue = String(value).slice(index + 1).trim();
if (!name || !cookieValue) return null;
return { name, value: cookieValue };
}
function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) {
const payload = JSON.stringify(body);
const setCookie = headers["set-cookie"];
@@ -114,7 +114,6 @@ test("cloud web auth bootstrap auto-logins only after an unauthenticated session
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 {
@@ -123,6 +122,87 @@ test("cloud web auth bootstrap auto-logins only after an unauthenticated session
}
});
test("cloud web workspace bootstrap keeps an existing session and includes workspace", async () => {
const upstreamRequests = [];
const workspacePayload = { workspace: { workspaceId: "ws_existing", selectedConversationId: "cnv_1", selectedConversation: { conversationId: "cnv_1", messages: [] } } };
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(request.url?.startsWith("/v1/workbench/workspace") ? workspacePayload : { authenticated: true, actor: { username: "alice" } }));
});
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/workspace-bootstrap?projectId=prj_device_pod_workbench`, { headers: { cookie: "hwlab_session=alice" } });
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "alice" }, projectId: "prj_device_pod_workbench", workspace: workspacePayload.workspace, workspaceStatus: 200, workspaceError: null });
assert.deepEqual(upstreamRequests, [
{ method: "GET", url: "/auth/session", cookie: "hwlab_session=alice" },
{ method: "GET", url: "/v1/workbench/workspace?projectId=prj_device_pod_workbench", cookie: "hwlab_session=alice" }
]);
} finally {
await close(cloudWeb);
await close(upstream);
}
});
test("cloud web workspace bootstrap reads workspace with auto-login cookie", async () => {
const upstreamRequests = [];
const workspacePayload = { workspace: { workspaceId: "ws_auto", selectedConversationId: "cnv_auto", selectedConversation: { conversationId: "cnv_auto", messages: [{ id: "msg_1", role: "agent", title: "Code Agent", text: "ready", status: "completed", createdAt: "2026-06-05T00:00:00.000Z" }] } } };
const upstream = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie, 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" } } : request.url?.startsWith("/v1/workbench/workspace") ? workspacePayload : { 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/workspace-bootstrap?projectId=prj_device_pod_workbench`);
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" }, projectId: "prj_device_pod_workbench", workspace: workspacePayload.workspace, workspaceStatus: 200, workspaceError: null });
assert.deepEqual(upstreamRequests, [
{ method: "POST", url: "/auth/login", cookie: undefined, body: JSON.stringify({ username: "admin", password: "hwlab2026" }) },
{ method: "GET", url: "/v1/workbench/workspace?projectId=prj_device_pod_workbench", cookie: "hwlab_session=session-auto", body: "" }
]);
} finally {
await close(cloudWeb);
await close(upstream);
}
});
test("cloud web proxies Admin Access write routes", async () => {
const upstreamRequests = [];
const upstream = createServer(async (request, response) => {
@@ -113,6 +113,8 @@ export function shouldAutoReplayTrace(message: ChatMessage): boolean {
return events.length === 0 && declaredCount > 0;
}
const HISTORICAL_TRACE_REPLAY_DELAY_MS = 4500;
function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement {
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
const replayRequestedRef = useRef(false);
@@ -120,7 +122,8 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
if (replayRequestedRef.current) return;
if (!shouldAutoReplayTrace(message)) return;
replayRequestedRef.current = true;
onReplayTrace();
const timer = window.setTimeout(() => onReplayTrace(), HISTORICAL_TRACE_REPLAY_DELAY_MS);
return () => window.clearTimeout(timer);
}, [message, onReplayTrace]);
if (message.role === "agent") {
const hasFinalResponse = message.status !== "running" && message.text.trim().length > 0;
+13 -2
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../services/api/client";
import type { AuthSession, AuthState, AuthUser } from "../types/domain";
import { WORKBENCH_PROJECT_ID } from "../state/constants";
import type { AuthSession, AuthState, AuthUser, WorkspaceRecord } from "../types/domain";
import { asRecord, nonEmptyString } from "../utils";
const DEFAULT_USERNAME = "admin";
@@ -25,9 +26,13 @@ export function useAuth(): UseAuthResult {
async function run(): Promise<void> {
const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth;
const mode = config?.mode ?? "auto";
const current = mode === "auto" ? await api.authBootstrap() : await api.session();
const current = mode === "auto" ? await api.workspaceBootstrap(WORKBENCH_PROJECT_ID) : await api.session();
if (canceled) return;
if (current.ok && current.data?.authenticated === true) {
const workspace = workspaceFromPayload(current.data);
if (workspace) {
window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId: WORKBENCH_PROJECT_ID, workspace };
}
const next = sessionFromPayload(current.data);
setSession(next);
setAuthState("authenticated");
@@ -77,6 +82,12 @@ export function useAuth(): UseAuthResult {
return useMemo(() => ({ authState, session, error, login, logout }), [authState, session, error, login, logout]);
}
function workspaceFromPayload(payload: unknown): WorkspaceRecord | null {
const record = asRecord(payload);
const workspace = asRecord(record?.workspace);
return typeof workspace?.workspaceId === "string" && workspace.workspaceId.trim() ? workspace as unknown as WorkspaceRecord : null;
}
function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }): AuthSession {
return {
authenticated: payload.authenticated === true,
@@ -206,6 +206,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" }),
workspaceBootstrap: (projectId: string): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string; projectId?: string; workspace?: WorkspaceRecord | null; workspaceStatus?: number | null; workspaceError?: unknown }>> => fetchJson(`/auth/workspace-bootstrap?projectId=${encodeURIComponent(projectId)}`, { timeoutName: "workspace bootstrap", timeoutMs: 8000 }),
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 }),
+22 -1
View File
@@ -179,8 +179,19 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
}
hydrateInFlightRef.current = true;
dispatch({ type: "hydrate:start" });
const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
try {
const bootstrapWorkspace = consumeWorkspaceBootstrap();
if (bootstrapWorkspace) {
workspaceLoadedRef.current = true;
dispatch({
type: "hydrate:done",
workspace: bootstrapWorkspace,
messages: messagesFromWorkspace(bootstrapWorkspace)
});
if (enabledRef.current) scheduleConversationList();
return;
}
const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID);
if (!workspaceResult.ok) {
workspaceLoadedRef.current = false;
dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" });
@@ -475,6 +486,16 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
}
function consumeWorkspaceBootstrap(): WorkspaceRecord | null {
if (typeof window === "undefined") return null;
const bootstrap = window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP;
if (!bootstrap || bootstrap.consumed === true || bootstrap.projectId !== WORKBENCH_PROJECT_ID) return null;
bootstrap.consumed = true;
window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = undefined;
return bootstrap.workspace?.workspaceId ? bootstrap.workspace : null;
}
function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: DevicePodItem[], requestedDevicePodId: string): string {
const payloadSelected = nonEmptyString(payload?.selectedDevicePodId);
const requested = nonEmptyString(requestedDevicePodId);
+9
View File
@@ -1,3 +1,5 @@
import type { WorkspaceRecord } from "./domain";
export interface WorkbenchAuthConfig {
mode?: "server" | "auto";
username?: string;
@@ -22,9 +24,16 @@ export interface WorkbenchConfig {
timeouts?: WorkbenchTimeoutConfig;
}
export interface WorkbenchWorkspaceBootstrap {
projectId?: string;
workspace?: WorkspaceRecord | null;
consumed?: boolean;
}
declare global {
interface Window {
HWLAB_CLOUD_WEB_CONFIG?: WorkbenchConfig;
HWLAB_CLOUD_WEB_TIMEOUTS?: WorkbenchTimeoutConfig;
HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: WorkbenchWorkspaceBootstrap;
}
}