diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4473cbdf..1401fb12 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -22,7 +22,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 `config/hwlab-node-lanes.yaml` 的 `bootstrapAdmin` 声明与受控 `hwlab nodes secret status|ensure --node --lane v03 --name hwlab-v03-bootstrap-admin` CLI。明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` sourceRef 文件;CLI 在本地把明文转换为 HWLAB 兼容 password hash,只向运行面同步 `password-hash`,并在输出中只披露 sourceRef、sourceKey、target Secret/key、presence、byte count、fingerprint、mutation 与后续命令。`secret ensure --force` 只用于明确需要按 YAML sourceRef 重灌 bootstrap admin hash 并重启 Cloud API 的受控恢复场景,默认 ensure 不做强制重灌;不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。 -`hwlab nodes web-probe run --node --lane [--url ]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node --lane <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。 +`hwlab nodes web-probe run --node --lane [--url ]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node --lane <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。`web-probe script` 托管登录先对同源 `/auth/login` 做短重试;仍未拿到 `hwlab_session` 时自动回到当前 Cloud Web 登录表单,以浏览器方式提交同一凭据。`probe.auth` 只输出 method、status、attempts、fallback 和 redacted errorSummary,不打印密码、cookie 或可复制 session 值。 `hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。 diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index e3924ad2..6b9906d4 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -3815,39 +3815,259 @@ try { } async function authenticate(browserContext) { + const apiAuth = await authenticateWithApiRetries(browserContext); + if (apiAuth.ok) return apiAuth; + return authenticateWithFormFallback(browserContext, apiAuth); +} + +async function authenticateWithApiRetries(browserContext) { const loginUrl = new URL("/auth/login", baseUrl).toString(); - const response = await browserContext.request.post(loginUrl, { - data: { username, password }, - timeout: timeoutMs, - }); + const attempts = []; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await browserContext.request.post(loginUrl, { + data: { username, password }, + headers: { accept: "application/json", "content-type": "application/json" }, + timeout: Math.min(timeoutMs, 12000), + }); + const summary = await responseSummary(response); + const cookieState = await readAuthCookieState(browserContext); + const item = { + attempt, + method: "api", + ...summary, + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + }; + attempts.push(item); + if (response.ok() && cookieState.cookiePresent) { + return { + ok: true, + method: "api", + loginUrl: new URL("/auth/login", baseUrl).pathname, + status: response.status(), + statusText: response.statusText(), + cookiePresent: true, + cookieNames: cookieState.cookieNames, + attempts, + valuesRedacted: true, + }; + } + if (response.status() < 500 && response.status() !== 429) break; + } catch (error) { + attempts.push({ + attempt, + method: "api", + status: 0, + statusText: "request-error", + error: error instanceof Error ? error.message : String(error), + cookiePresent: false, + }); + } + if (attempt < 3) await sleep(300 * attempt); + } + const cookieState = await readAuthCookieState(browserContext); + const last = attempts[attempts.length - 1] ?? null; + return { + ok: false, + method: "api", + loginUrl: new URL("/auth/login", baseUrl).pathname, + status: typeof last?.status === "number" ? last.status : 0, + statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed", + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + attempts, + errorSummary: last, + valuesRedacted: true, + }; +} + +async function authenticateWithFormFallback(browserContext, apiAuth) { + const loginPage = await browserContext.newPage(); + const fallback = { + method: "form", + startPath: "/workbench", + surface: null, + finalPath: null, + response: null, + outcome: null, + error: null, + }; + try { + await loginPage.goto(new URL("/workbench", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: timeoutMs }); + const surface = await waitForAuthSurface(loginPage); + fallback.surface = surface; + if (surface === "workspace") { + const cookieState = await readAuthCookieState(browserContext); + fallback.finalPath = new URL(loginPage.url()).pathname; + return { + ok: cookieState.cookiePresent, + method: "form-fallback", + loginUrl: new URL("/auth/login", baseUrl).pathname, + status: cookieState.cookiePresent ? 200 : 0, + statusText: cookieState.cookiePresent ? "already-authenticated" : "workspace-without-session-cookie", + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + attempts: apiAuth.attempts, + apiErrorSummary: apiAuth.errorSummary, + fallback, + valuesRedacted: true, + }; + } + if (surface !== "login-card" && surface !== "legacy-id") { + throw new Error("login form not visible"); + } + + const usernameInput = loginPage.locator('#login-username, input[autocomplete="username"], .login-card input').first(); + const passwordInput = loginPage.locator('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first(); + const submitButton = loginPage.locator('#login-submit, .login-card button[type="submit"], button[type="submit"]').first(); + await usernameInput.fill(username); + await passwordInput.fill(password); + await loginPage.waitForFunction((expected) => { + const user = document.querySelector('#login-username, input[autocomplete="username"], .login-card input'); + const pass = document.querySelector('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]'); + const submit = document.querySelector('#login-submit, .login-card button[type="submit"], button[type="submit"]'); + return Boolean(user && pass && user.value === expected.user && pass.value.length === expected.passwordLength && !submit?.disabled); + }, { user: username, passwordLength: password.length }, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null); + + const loginResponsePromise = loginPage.waitForResponse((response) => responsePathMatches(response.url(), "/auth/login"), { timeout: Math.min(timeoutMs, 15000) }).catch(() => null); + await Promise.allSettled([ + loginPage.waitForFunction(() => document.querySelector("#workspace") || document.querySelector("#command-input"), null, { timeout: Math.min(timeoutMs, 15000) }), + submitButton.click(), + ]); + const loginResponse = await loginResponsePromise; + if (loginResponse) fallback.response = await responseSummary(loginResponse); + fallback.outcome = await formLoginOutcome(loginPage); + fallback.finalPath = new URL(loginPage.url()).pathname; + assertNoCredentialLeak(loginPage.url()); + + const cookieState = await readAuthCookieState(browserContext); + return { + ok: cookieState.cookiePresent, + method: "form-fallback", + loginUrl: new URL("/auth/login", baseUrl).pathname, + status: fallback.response?.status ?? (cookieState.cookiePresent ? 200 : 0), + statusText: fallback.response?.statusText ?? (cookieState.cookiePresent ? "OK" : "form-login-no-cookie"), + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + attempts: apiAuth.attempts, + apiErrorSummary: apiAuth.errorSummary, + fallback, + valuesRedacted: true, + }; + } catch (error) { + fallback.error = error instanceof Error ? error.message : String(error); + fallback.finalPath = loginPage ? new URL(loginPage.url()).pathname : null; + const cookieState = await readAuthCookieState(browserContext).catch(() => ({ cookiePresent: false, cookieNames: [] })); + return { + ok: false, + method: "form-fallback", + loginUrl: new URL("/auth/login", baseUrl).pathname, + status: fallback.response?.status ?? 0, + statusText: fallback.response?.statusText ?? "form-login-failed", + cookiePresent: cookieState.cookiePresent, + cookieNames: cookieState.cookieNames, + attempts: apiAuth.attempts, + apiErrorSummary: apiAuth.errorSummary, + fallback, + valuesRedacted: true, + }; + } finally { + await loginPage.close().catch(() => {}); + } +} + +async function waitForAuthSurface(targetPage) { + const handle = await targetPage.waitForFunction(() => { + if (document.querySelector("#workspace") || document.querySelector("#command-input")) return "workspace"; + if (document.querySelector("#login-username")) return "legacy-id"; + if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card"; + if (document.querySelector(".login-card input")) return "login-card"; + return ""; + }, null, { timeout: Math.min(timeoutMs, 12000) }).catch(() => null); + if (!handle) return null; + return handle.jsonValue().catch(() => null); +} + +async function formLoginOutcome(targetPage) { + const handle = await targetPage.waitForFunction(() => { + if (document.querySelector("#workspace") || document.querySelector("#command-input")) return { kind: "workspace" }; + const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim(); + if (errorText) return { kind: "error", errorText }; + return null; + }, null, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null); + if (!handle) return null; + return handle.jsonValue().catch(() => null); +} + +async function responseSummary(response) { + const status = response.status(); + let bodyPreview = null; + if (status >= 400) { + try { + bodyPreview = redactString(await response.text()).replace(/\s+/gu, " ").slice(0, 500); + } catch { + bodyPreview = null; + } + } + return { + status, + statusText: response.statusText(), + bodyPreview, + }; +} + +async function readAuthCookieState(browserContext) { const cookies = await browserContext.cookies(baseUrl); cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean); const cookieNames = cookies.map((cookie) => cookie.name).sort(); - const cookiePresent = cookieNames.includes("hwlab_session"); return { - ok: response.ok() && cookiePresent, - loginUrl: new URL("/auth/login", baseUrl).pathname, - status: response.status(), - statusText: response.statusText(), - cookiePresent, + cookiePresent: cookieNames.includes("hwlab_session"), cookieNames, - valuesRedacted: true, }; } +function responsePathMatches(rawUrl, path) { + try { + return new URL(rawUrl).pathname === path; + } catch { + return false; + } +} + +function assertNoCredentialLeak(value) { + const finalUrl = new URL(value); + if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) { + throw new Error("login credentials leaked into URL query"); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function publicAuth(value) { return { ok: value.ok, + method: value.method ?? null, loginPath: value.loginUrl, status: value.status, statusText: value.statusText, cookiePresent: value.cookiePresent, cookieNames: value.cookieNames, + attempts: value.attempts ?? null, + fallback: value.fallback ?? null, + errorSummary: cloneSummary(value.errorSummary ?? value.apiErrorSummary ?? null), username, valuesRedacted: true, }; } +function cloneSummary(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null; + return { ...value }; +} + function normalizeBaseUrl(raw) { if (!raw) throw new Error("missing HWLAB_WEB_BASE_URL"); const parsed = new URL(raw);