diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index dde01192..1b7a2db6 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -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; } diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index bec2003f..88777637 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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(/]*\bid=["']login-shell["'][^>]*>/u)?.[0] ?? ""; + const appShellTag = html.match(/]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? ""; + const loginHtml = html.match(/]*\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(/]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? ""; return ( labelsPresent(html, workbenchMarkers) && /\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, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 3771f5b5..0a36076d 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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()); diff --git a/web/hwlab-cloud-web/auth.mjs b/web/hwlab-cloud-web/auth.mjs new file mode 100644 index 00000000..b99f7f4d --- /dev/null +++ b/web/hwlab-cloud-web/auth.mjs @@ -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; +} diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 344d1e22..b2cd095f 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -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"> diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index ddcd4ed0..5c910e6b 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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", diff --git a/web/hwlab-cloud-web/scripts/dist-contract.mjs b/web/hwlab-cloud-web/scripts/dist-contract.mjs index b11994c3..100bc624 100644 --- a/web/hwlab-cloud-web/scripts/dist-contract.mjs +++ b/web/hwlab-cloud-web/scripts/dist-contract.mjs @@ -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", diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 7e76398b..4c4a1590 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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 {