fix: harden hwlab web probe script login

This commit is contained in:
Codex
2026-06-16 03:43:33 +00:00
parent c670ad8d92
commit 4a567abed6
2 changed files with 232 additions and 12 deletions
+1 -1
View File
@@ -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 <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 <node> --lane <lane> [--url <public-origin>]` 是 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 <node> --lane <lane> <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 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 <node> --lane <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 当成输入基础镜像。
+231 -11
View File
@@ -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);