feat: add cloud workbench login entry
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user