fix: collapse v02 workspace first screen bootstrap
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user