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
+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);