feat: add cloud workbench login entry

This commit is contained in:
Code Queue Review
2026-05-23 18:08:51 +00:00
parent fcfc2b01b0
commit 6861239a47
8 changed files with 1210 additions and 11 deletions
+133
View File
@@ -303,6 +303,7 @@ function runtimeScriptBase64() {
import { createServer } from "node:http";
import http from "node:http";
import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
@@ -317,6 +318,14 @@ const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TI
min: 1000,
max: 300000
});
const authUsername = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_USERNAME", "admin");
const authPassword = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_PASSWORD", "hwlab2026");
const authSessionTtlMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_AUTH_SESSION_TTL_MS, 12 * 60 * 60 * 1000, {
min: 60 * 1000,
max: 7 * 24 * 60 * 60 * 1000
});
const authCookieName = "hwlab_cloud_web_session";
const authSessions = new Map();
const readOnlyRpcMethods = new Set([
"system.health",
"cloud.adapter.describe",
@@ -366,6 +375,11 @@ function sendJson(response, statusCode, body) {
response.end(payload);
}
function nonEmptyEnv(name, fallback) {
const value = process.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function healthPayload() {
return {
serviceId,
@@ -388,6 +402,114 @@ function runNodeEntrypoint(file) {
});
}
function cookieValue(request, name) {
const raw = request.headers.cookie || "";
for (const part of raw.split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === name) return decodeURIComponent(rest.join("=") || "");
}
return "";
}
function authCookie(token, expiresAt) {
const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
return authCookieName + "=" + encodeURIComponent(token) + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + maxAgeSeconds;
}
function clearAuthCookie() {
return authCookieName + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
}
function activeAuthSession(request) {
const token = cookieValue(request, authCookieName);
if (!token) return null;
const session = authSessions.get(token);
if (!session) return null;
if (session.expiresAt <= Date.now()) {
authSessions.delete(token);
return null;
}
return { token, ...session };
}
function cleanupExpiredAuthSessions() {
const now = Date.now();
for (const [token, session] of authSessions) {
if (session.expiresAt <= now) authSessions.delete(token);
}
}
function constantTimeEquals(a, b) {
const left = Buffer.from(String(a));
const right = Buffer.from(String(b));
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
async function handleCloudWebAuth(request, response, url) {
if (url.pathname === "/auth/session" && request.method === "GET") {
const session = activeAuthSession(request);
sendJson(response, 200, session ? {
authenticated: true,
user: { username: session.username },
expiresAt: new Date(session.expiresAt).toISOString()
} : { authenticated: false });
return true;
}
if (url.pathname === "/auth/login" && request.method === "POST") {
let body = {};
try {
body = JSON.parse(await readRequestBody(request) || "{}");
} catch {
sendJson(response, 400, { authenticated: false, error: "invalid_request" });
return true;
}
const username = typeof body.username === "string" ? body.username.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
if (!constantTimeEquals(username, authUsername) || !constantTimeEquals(password, authPassword)) {
sendJson(response, 401, { authenticated: false, error: "invalid_credentials" });
return true;
}
cleanupExpiredAuthSessions();
const token = randomBytes(32).toString("hex");
const expiresAt = Date.now() + authSessionTtlMs;
authSessions.set(token, { username: authUsername, expiresAt });
const payload = JSON.stringify({
authenticated: true,
user: { username: authUsername },
expiresAt: new Date(expiresAt).toISOString()
}, null, 2);
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"set-cookie": authCookie(token, expiresAt)
});
response.end(payload);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
const session = activeAuthSession(request);
if (session) authSessions.delete(session.token);
const payload = JSON.stringify({ authenticated: false }, null, 2);
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"set-cookie": clearAuthCookie()
});
response.end(payload);
return true;
}
if (url.pathname.startsWith("/auth/")) {
sendJson(response, 404, { authenticated: false, error: "not_found" });
return true;
}
return false;
}
async function readRequestBody(request) {
let body = "";
for await (const chunk of request) {
@@ -520,11 +642,22 @@ async function serveCloudWeb() {
sendJson(response, 200, healthPayload());
return;
}
if (await handleCloudWebAuth(request, response, url)) {
return;
}
if (
(request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || url.pathname.startsWith("/v1/"))) ||
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io"))
) {
if (!activeAuthSession(request)) {
sendJson(response, 401, {
status: "failed",
error: "auth_required",
serviceId
});
return;
}
await proxyCloudApi(request, response, url);
return;
}
+533 -6
View File
@@ -72,6 +72,7 @@ const readOnlyRpcMethods = Object.freeze([
const requiredWebAssets = Object.freeze([
"index.html",
"styles.css",
"auth.mjs",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
@@ -86,9 +87,10 @@ const requiredWebAssets = Object.freeze([
"third_party/marked/LICENSE"
]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]);
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "auth.mjs", "app.mjs", "code-agent-facts.mjs", "code-agent-status.mjs", "live-status.mjs", "wiring-status.mjs"]);
const chineseWorkbenchLabels = Object.freeze([
"云工作台登录",
"HWLAB 云工作台",
"硬件资源",
"Agent 对话",
@@ -98,6 +100,11 @@ const chineseWorkbenchLabels = Object.freeze([
"使用说明"
]);
const defaultAuthCredentials = Object.freeze({
username: "admin",
password: "hwlab2026"
});
const defaultHomepageForbiddenTerms = Object.freeze([
"Gate",
"诊断",
@@ -370,6 +377,7 @@ export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) {
export async function runDevCloudWorkbenchSmoke(argv = []) {
const args = parseSmokeArgs(argv);
if (args.mode === "live") return runLiveSmoke(args);
if (args.mode === "auth-fixture") return runDevCloudWorkbenchAuthFixtureSmoke();
if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke();
if (args.mode === "dom-only") return runLiveDomOnlySmoke(args);
if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke();
@@ -440,6 +448,8 @@ export function parseSmokeArgs(argv) {
args.mode = "local-agent-fixture";
} else if (arg === "--quick-prompts-fixture") {
args.mode = "quick-prompts-fixture";
} else if (arg === "--auth-fixture") {
args.mode = "auth-fixture";
} else if (arg === "--local-agent-timeout-fixture") {
args.mode = "local-agent-timeout-fixture";
} else if (arg === "--url") {
@@ -476,9 +486,14 @@ function runStaticSmoke() {
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
});
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Default route is the VS Code-style Cloud Workbench.", {
addCheck(checks, blockers, "default-login-entry", hasDefaultLoginEntry(files, artifactPublisher), "Default 16666 route shows the Chinese login page before the Cloud Workbench is exposed.", {
blocker: "runtime_blocker",
evidence: ["workspace view is visible by default", "Gate view is hidden by default", ...workbenchMarkers]
evidence: ["云工作台登录", "账号或密码不正确,请重新输入。", "data-app-shell hidden", "auth.mjs", "HWLAB_CLOUD_WEB_AUTH_USERNAME", "HWLAB_CLOUD_WEB_AUTH_PASSWORD"]
});
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Authenticated route is the VS Code-style Cloud Workbench.", {
blocker: "runtime_blocker",
evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers]
});
addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", {
@@ -1657,6 +1672,7 @@ function readStaticFiles() {
return {
html: readText("web/hwlab-cloud-web/index.html"),
styles: readText("web/hwlab-cloud-web/styles.css"),
auth: readText("web/hwlab-cloud-web/auth.mjs"),
app: readText("web/hwlab-cloud-web/app.mjs"),
codeAgentFacts: readText("web/hwlab-cloud-web/code-agent-facts.mjs"),
codeAgentStatus: readText("web/hwlab-cloud-web/code-agent-status.mjs"),
@@ -1902,15 +1918,50 @@ function assetsExist() {
return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
}
function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = "") {
const loginShellTag = html.match(/<section\b[^>]*\bid=["']login-shell["'][^>]*>/u)?.[0] ?? "";
const appShellTag = html.match(/<main\b[^>]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? "";
const loginHtml = html.match(/<section\b[^>]*\bid=["']login-shell["'][\s\S]*?<\/section>/u)?.[0] ?? "";
const defaultShellText = visibleTextFromHtml(loginHtml);
const authSource = `${auth}\n${app}\n${styles}\n${artifactPublisher}`;
return (
loginShellTag.includes("data-auth-login") &&
!/\bhidden\b/u.test(loginShellTag) &&
/\bhidden\b/u.test(appShellTag) &&
/data-auth-state=["']checking["']/u.test(html) &&
["云工作台登录", "请输入账号和密码。", "用户名", "密码", "登录"].every((term) => defaultShellText.includes(term)) &&
!defaultShellText.includes("Gate") &&
!defaultShellText.includes("诊断") &&
!defaultShellText.includes("验收") &&
!defaultShellText.includes("后厨") &&
/DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) &&
/DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) &&
/HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) &&
/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) &&
/账号或密码不正确,请重新输入。/u.test(auth) &&
/ensureWorkbenchAuth/u.test(app) &&
/await\s+ensureWorkbenchAuth/u.test(app) &&
/initWorkbenchLogout/u.test(app) &&
/HWLAB_CLOUD_WEB_AUTH_USERNAME/u.test(authSource) &&
/HWLAB_CLOUD_WEB_AUTH_PASSWORD/u.test(authSource) &&
/auth_required/u.test(authSource) &&
/\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) &&
/\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) &&
/\.workbench-shell\[hidden\]/u.test(styles)
);
}
function hasDefaultWorkbench({ html, app }) {
const workspaceTag = firstTagForDataView(html, "workspace");
const gateTag = firstTagForDataView(html, "gate");
const appShellTag = html.match(/<main\b[^>]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? "";
return (
labelsPresent(html, workbenchMarkers) &&
/<title>\s*HWLAB 云工作台\s*<\/title>/u.test(html) &&
/data-route=["']workspace["']/u.test(html) &&
workspaceTag !== null &&
!/\bhidden\b/u.test(workspaceTag) &&
/\bhidden\b/u.test(appShellTag) &&
gateTag !== null &&
/\bhidden\b/u.test(gateTag) &&
finalRouteFallback(app) === "workspace"
@@ -2975,6 +3026,9 @@ async function inspectLiveDom(url, options = {}) {
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 8000 });
const login = await inspectLoginPage(page);
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
const dom = await page.evaluate(async (forbiddenTerms) => {
const workspace = document.querySelector('[data-view="workspace"]');
const gate = document.querySelector('[data-view="gate"]');
@@ -3075,6 +3129,9 @@ async function inspectLiveDom(url, options = {}) {
};
}, [...defaultHomepageForbiddenTerms]);
const pass =
login.loginVisible &&
login.workbenchHidden &&
login.copyOk &&
dom.title === "HWLAB 云工作台" &&
dom.bodyOverflow === "hidden" &&
dom.htmlOverflow === "hidden" &&
@@ -3114,6 +3171,8 @@ async function inspectLiveDom(url, options = {}) {
status: pass ? "pass" : "blocked",
summary: pass ? "Browser DOM confirms the default workbench, core controls, and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.",
evidence: [
`loginVisible=${login.loginVisible}`,
`workbenchHiddenBeforeLogin=${login.workbenchHidden}`,
`title=${dom.title}`,
`bodyOverflow=${dom.bodyOverflow}`,
`htmlOverflow=${dom.htmlOverflow}`,
@@ -3128,6 +3187,7 @@ async function inspectLiveDom(url, options = {}) {
...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : [])
],
observations: {
login,
...dom,
...(postGuard ? { codeAgentPostGuard: postGuard.observation() } : {})
}
@@ -3161,6 +3221,7 @@ async function inspectLiveHelpRoute(url, options = {}) {
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 });
await loginWithDefaultCredentials(page);
await page.locator('[data-route="help"]').click();
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const help = await inspectHelpMarkdownRoute(page);
@@ -3217,6 +3278,7 @@ async function inspectLiveGateRoute(url, options = {}) {
});
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
await loginWithDefaultCredentials(page);
await page.waitForFunction(() => {
const rows = document.querySelectorAll("#gate-review-body tr");
const source = document.querySelector("#gate-source-status")?.textContent ?? "";
@@ -3323,6 +3385,395 @@ async function inspectLiveGateRoute(url, options = {}) {
}
}
export async function runDevCloudWorkbenchAuthFixtureSmoke() {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Auth fixture smoke skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "auth-fixture-browser",
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [
{
id: "auth-browser-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}
],
blockers: [
{
type: "environment_blocker",
scope: "auth-browser-dependency",
status: "open",
summary
}
],
safety: staticSafety()
};
}
const server = await startStaticWebServer({ authFixture: true });
let browser;
try {
browser = await chromium.launch({ headless: true });
const desktop = await inspectAuthFixtureViewport(browser, server.url, {
width: 1366,
height: 768,
isMobile: false
});
const mobile = await inspectAuthFixtureViewport(browser, server.url, {
width: 390,
height: 844,
isMobile: true
});
const checks = [
{
id: "auth-login-success-desktop",
status: desktop.success.pass ? "pass" : "blocked",
viewport: desktop.viewport,
summary: "Desktop login accepts admin/hwlab2026 and enters the current Cloud Workbench.",
observations: desktop.success
},
{
id: "auth-login-failure-desktop",
status: desktop.failure.pass ? "pass" : "blocked",
viewport: desktop.viewport,
summary: "Desktop login failure stays on the Chinese login page with a bounded error message.",
observations: desktop.failure
},
{
id: "auth-refresh-session-desktop",
status: desktop.refresh.pass ? "pass" : "blocked",
viewport: desktop.viewport,
summary: "Desktop refresh preserves the authenticated workbench session.",
observations: desktop.refresh
},
{
id: "auth-logout-expiry-desktop",
status: desktop.logout.pass && desktop.expired.pass ? "pass" : "blocked",
viewport: desktop.viewport,
summary: "Desktop logout and expired session both return to the login page.",
observations: {
logout: desktop.logout,
expired: desktop.expired
}
},
{
id: "auth-login-success-mobile",
status: mobile.success.pass ? "pass" : "blocked",
viewport: mobile.viewport,
summary: "390x844 mobile login enters the workbench without outer scrolling or hidden controls.",
observations: mobile.success
},
{
id: "auth-login-failure-mobile",
status: mobile.failure.pass ? "pass" : "blocked",
viewport: mobile.viewport,
summary: "390x844 mobile login failure keeps the form visible and error text contained.",
observations: mobile.failure
},
{
id: "auth-refresh-session-mobile",
status: mobile.refresh.pass ? "pass" : "blocked",
viewport: mobile.viewport,
summary: "390x844 mobile refresh preserves the authenticated session and workbench layout.",
observations: mobile.refresh
},
{
id: "auth-logout-expiry-mobile",
status: mobile.logout.pass && mobile.expired.pass ? "pass" : "blocked",
viewport: mobile.viewport,
summary: "390x844 mobile logout and expired session return to login without outer scroll.",
observations: {
logout: mobile.logout,
expired: mobile.expired
}
}
];
const blockers = checks
.filter((check) => check.status !== "pass")
.map((check) => ({
type: "runtime_blocker",
scope: check.id,
status: "open",
summary: check.summary,
viewport: check.viewport
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "auth-fixture-browser",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Local browser fixture validates the Cloud Workbench login entry, failure message, session refresh, logout, expiry, and desktop/mobile layout.",
refs: ["pikasTech/HWLAB#357", "pikasTech/HWLAB#108"],
checks,
blockers,
safety: {
...staticSafety(),
localFixtureOnly: true,
hardwareWriteApis: false,
codeAgentPostSent: false,
statement: "Auth fixture uses local same-origin /auth endpoints only; it does not call hardware write APIs, post Code Agent chat, read Secrets, or deploy DEV."
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "auth-fixture-browser",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: `Auth fixture smoke failed: ${error.message}`,
checks: [],
blockers: [
{
type: "runtime_blocker",
scope: "auth-fixture-browser",
status: "open",
summary: error.message
}
],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
async function inspectAuthFixtureViewport(browser, url, viewport) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
isMobile: viewport.isMobile === true
});
try {
const failurePage = await context.newPage();
await failurePage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
const initial = await inspectLoginPage(failurePage);
await failurePage.locator("#login-username").fill(defaultAuthCredentials.username);
await failurePage.locator("#login-password").fill("wrong-password");
await failurePage.locator("#login-submit").click();
await failurePage.waitForFunction(() => !document.querySelector("#login-error")?.hidden, null, { timeout: 8000 });
const failure = await inspectFailedLoginPage(failurePage, viewport);
await failurePage.close();
const successPage = await context.newPage();
await successPage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(successPage);
const success = await inspectAuthenticatedWorkbench(successPage, viewport);
await successPage.reload({ waitUntil: "networkidle", timeout: 15000 });
const refresh = await inspectAuthenticatedWorkbench(successPage, viewport);
await successPage.locator("#logout-button").click();
await successPage.waitForFunction(() => document.querySelector("#login-shell")?.hidden === false, null, { timeout: 8000 });
const logout = await inspectLoginPage(successPage, viewport);
await successPage.close();
const expiredPage = await context.newPage();
await expiredPage.addInitScript(({ username }) => {
window.localStorage.setItem("hwlab.cloudWorkbench.auth.v1", JSON.stringify({
version: 1,
username,
issuedAt: Date.now() - 120000,
expiresAt: Date.now() - 1000
}));
}, { username: defaultAuthCredentials.username });
await expiredPage.goto(url, { waitUntil: "networkidle", timeout: 15000 });
const expired = await inspectLoginPage(expiredPage, viewport);
await expiredPage.close();
return {
viewport: { width: viewport.width, height: viewport.height },
initial,
failure,
success,
refresh,
logout,
expired
};
} finally {
await context.close();
}
}
async function loginWithDefaultCredentials(page) {
const loginVisible = await page.locator("#login-shell").evaluate((element) => element.hidden === false).catch(() => false);
if (!loginVisible) return;
await page.locator("#login-username").fill(defaultAuthCredentials.username);
await page.locator("#login-password").fill(defaultAuthCredentials.password);
await page.locator("#login-submit").click();
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
}
async function inspectLoginPage(page, viewport = null) {
return page.evaluate((viewport) => {
const login = document.querySelector("#login-shell");
const panel = document.querySelector(".login-panel");
const appShell = document.querySelector("[data-app-shell]");
const title = document.querySelector("#login-title")?.textContent?.trim() ?? "";
const text = login?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const panelBox = panel?.getBoundingClientRect();
const inputBoxes = [...document.querySelectorAll(".login-form input, #login-submit")].map((element) => {
const box = element.getBoundingClientRect();
return {
id: element.id,
width: box.width,
height: box.height,
left: box.left,
right: box.right,
scrollWidth: element.scrollWidth,
clientWidth: element.clientWidth
};
});
const forbiddenTerms = ["Gate", "诊断", "验收", "后厨", "BLOCKED", "SOURCE", "DEV-LIVE", "trace", "Secret"];
const rootScrollLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2;
const noHorizontalOverflow =
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2 &&
inputBoxes.every((box) => box.scrollWidth <= box.clientWidth + 2);
const panelInViewport = Boolean(panelBox) &&
panelBox.left >= -1 &&
panelBox.right <= window.innerWidth + 1 &&
panelBox.top >= -1 &&
panelBox.bottom <= window.innerHeight + 1;
const copyOk =
title === "云工作台登录" &&
["请输入账号和密码。", "用户名", "密码", "登录"].every((term) => text.includes(term)) &&
forbiddenTerms.every((term) => !text.includes(term));
const loginVisible = Boolean(login && login.hidden === false && panelBox && panelBox.width > 0 && panelBox.height > 0);
const workbenchHidden = appShell ? appShell.hidden === true : false;
return {
viewport: viewport ?? { width: window.innerWidth, height: window.innerHeight },
title,
textSample: text.slice(0, 180),
bodyAuthState: document.body.dataset.authState ?? "",
loginVisible,
workbenchHidden,
copyOk,
rootScrollLocked,
noHorizontalOverflow,
panelInViewport,
inputBoxes,
pass: loginVisible && workbenchHidden && copyOk && rootScrollLocked && noHorizontalOverflow && panelInViewport
};
}, viewport);
}
async function inspectFailedLoginPage(page, viewport) {
return page.evaluate((viewport) => {
const error = document.querySelector("#login-error");
const password = document.querySelector("#login-password");
const login = document.querySelector("#login-shell");
const appShell = document.querySelector("[data-app-shell]");
const errorBox = error?.getBoundingClientRect();
const message = error?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const internalLeak = /stack|trace|secret|token|HWLAB_CLOUD|Exception|Error:/iu.test(message);
const rootScrollLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2;
const errorContained = Boolean(errorBox) &&
errorBox.width > 0 &&
error.scrollWidth <= error.clientWidth + 2 &&
errorBox.left >= 0 &&
errorBox.right <= window.innerWidth + 1;
const pass =
login?.hidden === false &&
appShell?.hidden === true &&
message === "账号或密码不正确,请重新输入。" &&
document.activeElement === password &&
!internalLeak &&
rootScrollLocked &&
errorContained;
return {
viewport,
pass,
message,
internalLeak,
passwordFocused: document.activeElement === password,
loginVisible: login?.hidden === false,
workbenchHidden: appShell?.hidden === true,
rootScrollLocked,
errorContained
};
}, viewport);
}
async function inspectAuthenticatedWorkbench(page, viewport) {
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
return page.evaluate((viewport) => {
const login = document.querySelector("#login-shell");
const appShell = document.querySelector("[data-app-shell]");
const visible = (selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
return box.width > 0 && box.height > 0 && style.visibility !== "hidden" && style.display !== "none";
};
window.scrollTo(0, 240);
document.documentElement.scrollTop = 240;
document.body.scrollTop = 240;
const rootAfterScrollAttempt = {
windowScrollY: window.scrollY,
htmlScrollTop: document.documentElement.scrollTop,
bodyScrollTop: document.body.scrollTop
};
const rootScrollLocked =
getComputedStyle(document.documentElement).overflow === "hidden" &&
getComputedStyle(document.body).overflow === "hidden" &&
document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 &&
document.body.scrollHeight <= document.body.clientHeight + 2 &&
rootAfterScrollAttempt.windowScrollY === 0 &&
rootAfterScrollAttempt.htmlScrollTop === 0 &&
rootAfterScrollAttempt.bodyScrollTop === 0;
const noHorizontalOverflow =
document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2 &&
document.body.scrollWidth <= document.body.clientWidth + 2;
const controlsVisible = {
commandInput: visible("#command-input"),
commandSend: visible("#command-send"),
logout: visible("#logout-button"),
hardwareArea: visible(".hardware-status") && visible("[data-hardware-tab='overview']"),
m3Control: visible("#m3-control-form")
};
const pass =
login?.hidden === true &&
appShell?.hidden === false &&
document.body.dataset.authState === "authenticated" &&
rootScrollLocked &&
noHorizontalOverflow &&
Object.values(controlsVisible).every(Boolean);
return {
viewport,
pass,
loginHidden: login?.hidden === true,
workbenchVisible: appShell?.hidden === false,
bodyAuthState: document.body.dataset.authState ?? "",
rootScrollLocked,
rootAfterScrollAttempt,
noHorizontalOverflow,
controlsVisible
};
}, viewport);
}
async function installNoCodeAgentPostGuard(page) {
const attempts = [];
await page.route("**/v1/agent/chat", async (route) => {
@@ -3363,6 +3814,7 @@ async function inspectLiveCodeAgentE2e(url) {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
const controls = await inspectJourneyControls(page);
if (!Object.values(controls).every(Boolean)) {
@@ -4003,6 +4455,7 @@ async function runLocalAgentFixtureSmoke({
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForFunction(
() => document.querySelector("#live-status")?.textContent?.trim() === "只读模式",
@@ -4217,15 +4670,19 @@ export async function runDevCloudWorkbenchMobileSmoke() {
isMobile: true
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
const closed = await inspectMobileWorkbench(page);
const workspaceScroll = await inspectWorkbenchScrollContract(page, { panelSelector: "#conversation-list" });
await page.click('[data-route="help"]');
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
await page.waitForTimeout(150);
const help = await inspectHelpMarkdownRoute(page);
await page.goto(new URL("/#/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const slashHashHelp = await inspectHelpMarkdownRoute(page);
await page.goto(new URL("/help", server.url).toString(), { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.waitForFunction(() => document.querySelector("#help-content")?.dataset.helpState === "ready", null, { timeout: 8000 });
const directHelp = await inspectHelpMarkdownRoute(page);
await page.click('[data-route="workspace"]');
@@ -4259,7 +4716,7 @@ export async function runDevCloudWorkbenchMobileSmoke() {
},
{
id: "mobile-help-markdown-route",
status: help.nonDefaultMarkdownHelp && help.routeIsHashHelp && help.termsPresent ? "pass" : "blocked",
status: help.nonDefaultMarkdownHelp && (help.routeIsHashHelp || help.routeIsSlashHashHelp) && help.termsPresent ? "pass" : "blocked",
summary: "Help opens from the rail as a non-default hash route rendered from help.md by the vendored Markdown renderer.",
observations: help
},
@@ -4492,6 +4949,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) {
});
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
const promptResults = [];
for (const prompt of codeAgentQuickPromptFixtures) {
@@ -4652,8 +5110,12 @@ async function inspectQuickPromptGeometry(page, viewport) {
async function startStaticWebServer(options = {}) {
const rootDir = path.resolve(options.rootDir ?? webRoot);
const authFixtureSessions = new Set();
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (options.authFixture && await handleAuthFixtureApi({ request, response, url, authFixtureSessions })) {
return;
}
if (options.m3StatusFixture && await handleM3StatusFixtureApi({ request, response, url, options })) {
return;
}
@@ -4693,6 +5155,64 @@ async function startStaticWebServer(options = {}) {
};
}
async function handleAuthFixtureApi({ request, response, url, authFixtureSessions }) {
if (url.pathname === "/auth/session" && request.method === "GET") {
const token = authFixtureCookie(request);
jsonResponse(response, 200, token && authFixtureSessions.has(token)
? {
authenticated: true,
user: { username: defaultAuthCredentials.username },
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
: { authenticated: false });
return true;
}
if (url.pathname === "/auth/login" && request.method === "POST") {
const body = await readJsonBody(request);
if (body?.username !== defaultAuthCredentials.username || body?.password !== defaultAuthCredentials.password) {
jsonResponse(response, 401, { authenticated: false, error: "invalid_credentials" });
return true;
}
const token = `auth_fixture_${authFixtureSessions.size + 1}`;
authFixtureSessions.add(token);
const payload = JSON.stringify({
authenticated: true,
user: { username: defaultAuthCredentials.username },
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
});
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"set-cookie": `hwlab_cloud_web_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600`
});
response.end(payload);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
const token = authFixtureCookie(request);
if (token) authFixtureSessions.delete(token);
response.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"set-cookie": "hwlab_cloud_web_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
});
response.end(JSON.stringify({ authenticated: false }));
return true;
}
if (url.pathname.startsWith("/auth/")) {
jsonResponse(response, 404, { authenticated: false, error: "not_found" });
return true;
}
return false;
}
function authFixtureCookie(request) {
const cookie = request.headers.cookie ?? "";
for (const part of cookie.split(";")) {
const [name, ...value] = part.trim().split("=");
if (name === "hwlab_cloud_web_session") return decodeURIComponent(value.join("=") || "");
}
return "";
}
async function handleQuickPromptsFixtureApi({ request, response, url }) {
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
jsonResponse(response, 200, {
@@ -5177,6 +5697,7 @@ async function inspectM3StatusFixtureViewport(browser, url, viewport) {
});
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await loginWithDefaultCredentials(page);
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
for (const tab of ["gateways", "box1", "box2", "patch", "overview"]) {
await page.locator(`[data-hardware-tab="${tab}"]`).click();
@@ -5264,6 +5785,7 @@ async function inspectM3StatusFixtureRefresh(browser, url) {
});
});
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
await loginWithDefaultCredentials(page);
await page.waitForFunction(() => document.querySelector("#hardware-source-status")?.textContent?.trim() === "实况受阻", null, { timeout: 8000 });
await page.locator('[data-hardware-tab="box1"]').click();
const initialBox1 = await hardwarePanelText(page);
@@ -5537,6 +6059,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport, options =
const artifacts = { screenshots: [] };
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const defaultLayout = await inspectLayoutState(page, {
@@ -5563,6 +6086,7 @@ async function inspectWorkbenchGateLayout(browser, url, viewport, options = {})
});
try {
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator('[data-view="gate"]').waitFor({ state: "visible", timeout: 12000 });
await page.waitForTimeout(150);
const currentRoute = await inspectGateLayoutState(page, { viewport, mode: "gate-current" });
@@ -6904,8 +7428,11 @@ async function inspectHelpMarkdownRoute(page) {
helpContent.scrollTop = 0;
await new Promise((resolve) => requestAnimationFrame(resolve));
const before = helpContent.scrollTop;
helpContent.scrollTop = helpContent.scrollHeight;
await new Promise((resolve) => requestAnimationFrame(resolve));
for (let attempt = 0; attempt < 3; attempt += 1) {
helpContent.scrollTop = helpContent.scrollHeight;
await new Promise((resolve) => requestAnimationFrame(resolve));
if (helpContent.scrollTop > before) break;
}
return {
before,
after: helpContent.scrollTop,
+10 -1
View File
@@ -1,4 +1,5 @@
import { gateSummary } from "./gate-summary.mjs";
import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.mjs";
import {
codeAgentAttributionFromMessage,
codeAgentFactsFromMessage,
@@ -91,7 +92,9 @@ const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view)
let rpcSequence = 0;
const el = {
loginShell: byId("login-shell"),
shell: query("[data-app-shell]"),
logoutButton: byId("logout-button"),
rightSidebarResize: byId("right-sidebar-resize"),
routePath: byId("route-path"),
liveStatus: byId("live-status"),
@@ -131,6 +134,11 @@ const el = {
helpStatus: byId("help-status")
};
await ensureWorkbenchAuth({
loginShell: el.loginShell,
appShell: el.shell
});
const state = {
conversationId: null,
sessionId: null,
@@ -165,6 +173,7 @@ initSideTabs();
initHardwareTabs();
initQuickActions();
initCommandBar();
initWorkbenchLogout(el.logoutButton);
initM3Control();
initGateControls();
renderStaticWorkbench();
@@ -200,7 +209,7 @@ function initRoutes() {
window.addEventListener("hashchange", () => showView(routeFromLocation()));
for (const button of document.querySelectorAll("[data-route]")) {
button.addEventListener("click", () => {
window.location.hash = button.dataset.route;
window.location.hash = `/${button.dataset.route}`;
});
}
showView(routeFromLocation());
+339
View File
@@ -0,0 +1,339 @@
const DEFAULT_AUTH_USERNAME = "admin";
const DEFAULT_AUTH_PASSWORD = "hwlab2026";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1";
const AUTH_ROUTE = Object.freeze({
session: "/auth/session",
login: "/auth/login",
logout: "/auth/logout"
});
let activeAuth = null;
let expiryTimer = null;
export async function ensureWorkbenchAuth({ loginShell, appShell }) {
if (!loginShell || !appShell) {
throw new Error("missing auth shell");
}
const config = resolveAuthConfig();
hideWorkbench(appShell);
showLogin(loginShell);
const session = await readActiveSession(config);
if (session.authenticated) {
return activateAuthenticatedWorkbench({ loginShell, appShell, session, config });
}
return new Promise((resolve) => {
initLoginForm({
loginShell,
appShell,
config,
onAuthenticated: (authSession) => {
resolve(activateAuthenticatedWorkbench({ loginShell, appShell, session: authSession, config }));
}
});
});
}
export function initWorkbenchLogout(button) {
if (!button) return;
button.addEventListener("click", async () => {
await logoutActiveSession();
window.location.reload();
});
}
function resolveAuthConfig() {
const configured = globalThis.HWLAB_CLOUD_WEB_CONFIG?.auth ?? {};
const sessionTtlMs = clampSessionTtl(configured.sessionTtlMs ?? configured.ttlMs ?? DEFAULT_SESSION_TTL_MS);
const mode = ["server", "local", "auto"].includes(configured.mode) ? configured.mode : "auto";
return {
mode,
username: nonEmptyString(configured.username, DEFAULT_AUTH_USERNAME),
password: nonEmptyString(configured.password, DEFAULT_AUTH_PASSWORD),
sessionTtlMs
};
}
function nonEmptyString(value, fallback) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function clampSessionTtl(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_TTL_MS;
return Math.min(Math.max(Math.trunc(parsed), 60_000), 7 * 24 * 60 * 60 * 1000);
}
async function readActiveSession(config) {
if (config.mode !== "local") {
const serverSession = await fetchServerSession();
if (serverSession.available) {
return {
authenticated: serverSession.authenticated,
mode: "server",
user: serverSession.user,
expiresAt: serverSession.expiresAt
};
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
}
return readLocalSession(config);
}
async function fetchServerSession() {
try {
const response = await fetch(AUTH_ROUTE.session, {
method: "GET",
credentials: "same-origin",
headers: { accept: "application/json" }
});
if (response.status === 404 || response.status === 405) {
return { available: false };
}
const payload = await response.json().catch(() => ({}));
return {
available: true,
authenticated: response.ok && payload.authenticated === true,
user: safeUser(payload.user),
expiresAt: stringOrNull(payload.expiresAt)
};
} catch {
return { available: false };
}
}
function readLocalSession(config) {
const now = Date.now();
const payload = readStoredLocalSession();
if (!payload || payload.expiresAt <= now || payload.username !== config.username) {
clearLocalSession();
return { authenticated: false, mode: "local" };
}
return {
authenticated: true,
mode: "local",
user: { username: payload.username },
expiresAt: new Date(payload.expiresAt).toISOString()
};
}
function readStoredLocalSession() {
try {
const raw = window.localStorage?.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
const expiresAt = Number(payload.expiresAt);
if (!Number.isFinite(expiresAt)) return null;
return {
username: typeof payload.username === "string" ? payload.username : "",
expiresAt
};
} catch {
return null;
}
}
function writeLocalSession(config) {
const expiresAt = Date.now() + config.sessionTtlMs;
try {
window.localStorage?.setItem(
AUTH_STORAGE_KEY,
JSON.stringify({
version: 1,
username: config.username,
issuedAt: Date.now(),
expiresAt
})
);
} catch {
// A session can still continue for the current page load when storage is unavailable.
}
return {
authenticated: true,
mode: "local",
user: { username: config.username },
expiresAt: new Date(expiresAt).toISOString()
};
}
function clearLocalSession() {
try {
window.localStorage?.removeItem(AUTH_STORAGE_KEY);
} catch {
// Ignore storage availability errors.
}
}
function initLoginForm({ loginShell, appShell, config, onAuthenticated }) {
const form = required(loginShell, "#login-form");
const usernameInput = required(loginShell, "#login-username");
const passwordInput = required(loginShell, "#login-password");
const submit = required(loginShell, "#login-submit");
const error = required(loginShell, "#login-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
setLoginError(error, "");
submit.disabled = true;
form.setAttribute("aria-busy", "true");
const username = usernameInput.value.trim();
const password = passwordInput.value;
const result = await attemptLogin({ username, password, config });
submit.disabled = false;
form.removeAttribute("aria-busy");
if (!result.authenticated) {
hideWorkbench(appShell);
showLogin(loginShell);
setLoginError(error, "账号或密码不正确,请重新输入。");
passwordInput.select();
passwordInput.focus();
return;
}
onAuthenticated(result);
});
window.requestAnimationFrame(() => usernameInput.focus());
}
async function attemptLogin({ username, password, config }) {
if (config.mode !== "local") {
const serverResult = await attemptServerLogin(username, password);
if (serverResult.available) {
return serverResult.authenticated
? {
authenticated: true,
mode: "server",
user: serverResult.user,
expiresAt: serverResult.expiresAt
}
: { authenticated: false, mode: "server" };
}
if (config.mode === "server") {
return { authenticated: false, mode: "server" };
}
}
if (username === config.username && password === config.password) {
return writeLocalSession(config);
}
return { authenticated: false, mode: "local" };
}
async function attemptServerLogin(username, password) {
try {
const response = await fetch(AUTH_ROUTE.login, {
method: "POST",
credentials: "same-origin",
headers: {
accept: "application/json",
"content-type": "application/json"
},
body: JSON.stringify({ username, password })
});
if (response.status === 404 || response.status === 405) {
return { available: false };
}
const payload = await response.json().catch(() => ({}));
return {
available: true,
authenticated: response.ok && payload.authenticated === true,
user: safeUser(payload.user),
expiresAt: stringOrNull(payload.expiresAt)
};
} catch {
return { available: false };
}
}
function activateAuthenticatedWorkbench({ loginShell, appShell, session, config }) {
activeAuth = { mode: session.mode, config };
hideLogin(loginShell);
showWorkbench(appShell);
scheduleExpiry(session.expiresAt);
return session;
}
function showLogin(loginShell) {
loginShell.hidden = false;
loginShell.removeAttribute("aria-hidden");
document.body.dataset.authState = "login";
}
function hideLogin(loginShell) {
loginShell.hidden = true;
loginShell.setAttribute("aria-hidden", "true");
}
function showWorkbench(appShell) {
appShell.hidden = false;
appShell.removeAttribute("aria-hidden");
document.body.dataset.authState = "authenticated";
}
function hideWorkbench(appShell) {
appShell.hidden = true;
appShell.setAttribute("aria-hidden", "true");
}
function setLoginError(error, message) {
error.textContent = message;
error.hidden = !message;
}
async function logoutActiveSession() {
window.clearTimeout(expiryTimer);
expiryTimer = null;
clearLocalSession();
if (activeAuth?.mode === "server") {
try {
await fetch(AUTH_ROUTE.logout, {
method: "POST",
credentials: "same-origin",
headers: { accept: "application/json" }
});
} catch {
// Reloading after a failed logout still returns to the login check path.
}
}
}
function scheduleExpiry(expiresAt) {
window.clearTimeout(expiryTimer);
expiryTimer = null;
const deadline = Date.parse(expiresAt ?? "");
if (!Number.isFinite(deadline)) return;
const remaining = deadline - Date.now();
if (remaining <= 0) {
void logoutActiveSession().then(() => window.location.reload());
return;
}
expiryTimer = window.setTimeout(() => {
void logoutActiveSession().then(() => window.location.reload());
}, Math.min(remaining, 2_147_483_647));
}
function required(root, selector) {
const element = root.querySelector(selector);
if (!element) throw new Error(`missing auth element ${selector}`);
return element;
}
function safeUser(user) {
const username = typeof user?.username === "string" && user.username.trim() ? user.username.trim() : DEFAULT_AUTH_USERNAME;
return { username };
}
function stringOrNull(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+25 -2
View File
@@ -7,8 +7,30 @@
<link rel="stylesheet" href="/styles.css" />
<script type="module" src="/app.mjs"></script>
</head>
<body>
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md">
<body data-auth-state="checking">
<section class="login-shell" id="login-shell" data-auth-login aria-labelledby="login-title">
<div class="login-panel">
<div class="login-heading">
<p class="eyebrow">HWLAB</p>
<h1 id="login-title">云工作台登录</h1>
<p>请输入账号和密码。</p>
</div>
<form class="login-form" id="login-form" autocomplete="on">
<label>
<span>用户名</span>
<input id="login-username" name="username" type="text" autocomplete="username" inputmode="text" required />
</label>
<label>
<span>密码</span>
<input id="login-password" name="password" type="password" autocomplete="current-password" required />
</label>
<p class="login-error" id="login-error" role="alert" hidden></p>
<button class="login-submit" id="login-submit" type="submit">登录</button>
</form>
</div>
</section>
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md" hidden aria-hidden="true">
<aside class="activity-rail" aria-label="工作台活动栏">
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
@@ -28,6 +50,7 @@
<strong id="live-status" class="tone-pending">等待验证</strong>
<small id="live-detail">正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。</small>
</div>
<button class="logout-button" id="logout-button" type="button">退出</button>
</header>
<section class="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
+24 -1
View File
@@ -7,6 +7,7 @@ import {
runDevCloudWorkbenchLocalAgentFixtureSmoke,
runDevCloudWorkbenchLayoutSmoke,
runDevCloudWorkbenchMobileSmoke,
runDevCloudWorkbenchAuthFixtureSmoke,
runDevCloudWorkbenchQuickPromptsFixtureSmoke,
runDevCloudWorkbenchStaticSmoke
} from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
@@ -26,6 +27,7 @@ const repoRoot = path.resolve(rootDir, "../..");
const requiredFiles = [
"index.html",
"styles.css",
"auth.mjs",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
@@ -49,6 +51,7 @@ for (const file of requiredFiles) {
const html = fs.readFileSync(path.resolve(rootDir, "index.html"), "utf8");
const styles = fs.readFileSync(path.resolve(rootDir, "styles.css"), "utf8");
const auth = fs.readFileSync(path.resolve(rootDir, "auth.mjs"), "utf8");
const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8");
const codeAgentFacts = fs.readFileSync(path.resolve(rootDir, "code-agent-facts.mjs"), "utf8");
const codeAgentStatus = fs.readFileSync(path.resolve(rootDir, "code-agent-status.mjs"), "utf8");
@@ -60,7 +63,7 @@ const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"),
const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-contract.mjs"), "utf8");
const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8");
const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8");
const frontendSource = `${html}\n${app}\n${artifactPublisher}`;
const frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`;
const requiredTrustedRecordTerms = Object.freeze([
"Code Agent 对话记录",
"接线/operation",
@@ -154,6 +157,15 @@ const quickPromptsFillCheck = quickPromptsFixtureSmoke.checks.find((check) => ch
const quickPromptsNoAutosendCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-write-no-autosend");
const quickPromptsCopyCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-copy-boundary");
const quickPromptsLayoutCheck = quickPromptsFixtureSmoke.checks.find((check) => check.id === "quick-prompts-mobile-layout");
const authFixtureSmoke = await runDevCloudWorkbenchAuthFixtureSmoke();
const authSuccessDesktopCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-login-success-desktop");
const authFailureDesktopCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-login-failure-desktop");
const authRefreshDesktopCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-refresh-session-desktop");
const authLogoutDesktopCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-logout-expiry-desktop");
const authSuccessMobileCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-login-success-mobile");
const authFailureMobileCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-login-failure-mobile");
const authRefreshMobileCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-refresh-session-mobile");
const authLogoutMobileCheck = authFixtureSmoke.checks.find((check) => check.id === "auth-logout-expiry-mobile");
const layoutSmokeReportPath = path.resolve(repoRoot, "tmp/dev-cloud-workbench-layout-web-check.json");
const layoutSmoke = await runDevCloudWorkbenchLayoutSmoke({ reportPath: layoutSmokeReportPath });
fs.mkdirSync(path.dirname(layoutSmokeReportPath), { recursive: true });
@@ -213,6 +225,17 @@ assert.equal(quickPromptsFillCheck?.status, "pass", "quick prompt buttons must f
assert.equal(quickPromptsNoAutosendCheck?.status, "pass", "HWLAB API write quick prompts must not auto-send");
assert.equal(quickPromptsCopyCheck?.status, "pass", "quick prompt labels must say HWLAB API / Skill CLI without direct hardware-control copy");
assert.equal(quickPromptsLayoutCheck?.status, "pass", "quick prompt strip must stay contained on desktop and 390x844 mobile");
assert.equal(authFixtureSmoke.status, "pass", JSON.stringify(authFixtureSmoke.blockers, null, 2));
assert.equal(authFixtureSmoke.evidenceLevel, "SOURCE");
assert.equal(authFixtureSmoke.devLive, false);
assert.equal(authSuccessDesktopCheck?.status, "pass", "desktop login must accept admin/hwlab2026 and enter the Cloud Workbench");
assert.equal(authFailureDesktopCheck?.status, "pass", "desktop login failure must show a bounded Chinese error and keep workbench hidden");
assert.equal(authRefreshDesktopCheck?.status, "pass", "desktop refresh must preserve authenticated session state");
assert.equal(authLogoutDesktopCheck?.status, "pass", "desktop logout and expired session must return to login");
assert.equal(authSuccessMobileCheck?.status, "pass", "390x844 mobile login must enter workbench without outer scroll or hidden controls");
assert.equal(authFailureMobileCheck?.status, "pass", "390x844 mobile login failure must keep form visible and error contained");
assert.equal(authRefreshMobileCheck?.status, "pass", "390x844 mobile refresh must preserve authenticated session state");
assert.equal(authLogoutMobileCheck?.status, "pass", "390x844 mobile logout and expired session must return to login");
assert.equal(
layoutSmoke.status,
"pass",
@@ -8,6 +8,7 @@ export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand;
export const cloudWebDistRuntimeFiles = Object.freeze([
"index.html",
"styles.css",
"auth.mjs",
"app.mjs",
"code-agent-facts.mjs",
"code-agent-status.mjs",
+145 -1
View File
@@ -37,6 +37,7 @@ body {
width: 100%;
height: 100%;
min-height: 0;
display: grid;
overflow: hidden;
background:
linear-gradient(90deg, rgba(212, 173, 67, 0.055) 1px, transparent 1px),
@@ -48,12 +49,21 @@ body {
}
body > [data-app-shell] {
grid-area: 1 / 1;
height: 100%;
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
}
body[data-auth-state="login"] {
background:
linear-gradient(90deg, rgba(212, 173, 67, 0.04) 1px, transparent 1px),
linear-gradient(0deg, rgba(111, 183, 169, 0.035) 1px, transparent 1px),
var(--bg);
background-size: 32px 32px;
}
button,
input {
font: inherit;
@@ -87,6 +97,136 @@ ul {
background: rgba(15, 17, 16, 0.88);
}
.workbench-shell[hidden],
body > [data-app-shell][hidden] {
display: none;
}
.login-shell {
grid-area: 1 / 1;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 0;
display: grid;
place-items: center;
padding: 18px;
overflow: hidden;
}
.login-shell[hidden] {
display: none;
}
.login-panel {
width: min(100%, 360px);
min-width: 0;
display: grid;
gap: 18px;
padding: 24px;
background: rgba(24, 27, 24, 0.98);
border: 1px solid var(--line-strong);
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.34);
}
.login-heading {
display: grid;
gap: 6px;
min-width: 0;
}
.login-heading h1 {
font-size: 22px;
}
.login-heading p:last-child {
color: var(--muted);
font-size: 12px;
}
.login-form {
min-width: 0;
display: grid;
gap: 12px;
}
.login-form label {
min-width: 0;
display: grid;
gap: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 760;
}
.login-form input {
width: 100%;
min-width: 0;
min-height: 42px;
padding: 0 11px;
border: 1px solid var(--line);
background: #101310;
color: var(--text);
outline: 0;
}
.login-form input:focus {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
}
.login-error {
min-height: 22px;
padding: 7px 9px;
background: rgba(231, 110, 94, 0.1);
border: 1px solid rgba(231, 110, 94, 0.52);
color: var(--bad);
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.login-error[hidden] {
display: none;
}
.login-submit,
.logout-button {
min-width: 0;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
font-weight: 800;
cursor: pointer;
}
.login-submit {
min-height: 42px;
padding: 0 14px;
background: var(--accent);
color: #171713;
}
.login-submit:hover,
.login-submit:focus-visible,
.logout-button:hover,
.logout-button:focus-visible {
border-color: var(--accent);
outline: 0;
}
.login-submit:disabled {
cursor: wait;
opacity: 0.72;
}
.logout-button {
align-self: center;
min-height: 34px;
padding: 0 12px;
font-size: 11px;
}
.activity-rail,
.right-sidebar,
.center-workspace {
@@ -276,7 +416,7 @@ h3 {
.topbar {
min-height: 82px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px) auto;
gap: 12px;
align-items: stretch;
padding: 12px;
@@ -1711,6 +1851,10 @@ tbody tr:last-child td {
grid-template-columns: 1fr;
}
.logout-button {
justify-self: start;
}
.hardware-list,
.m3-control-grid,
.m3-control-actions {