feat(web-probe): add multi-sentinel registry

This commit is contained in:
Codex
2026-06-26 12:42:04 +00:00
parent 7b3df965cc
commit 4e0f1cba21
25 changed files with 1038 additions and 140 deletions
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
@@ -340,6 +341,10 @@ async function processCommand(command) {
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context, page);
case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
case "logout": return withObserverSync(await logoutAccount(command), "logout");
case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
@@ -592,7 +597,7 @@ async function authenticate(browserContext, authPage) {
throw error;
}
async function pageAuthLogin(authPage, loginUrl) {
async function pageAuthLogin(authPage, loginUrl, credential = { username, password }) {
if (!authPage) throw new Error("auth page is not ready");
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
return authPage.evaluate(async (input) => {
@@ -608,7 +613,156 @@ async function pageAuthLogin(authPage, loginUrl) {
status: response.status,
statusText: response.statusText || "",
};
}, { username, password, loginUrl });
}, { username: credential.username, password: credential.password, loginUrl });
}
async function loginAccount(command) {
const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
const credential = credentialForAccount(accountId);
const loginUrl = new URL("/auth/login", baseUrl).toString();
const before = await accountSessionSnapshot();
const response = await pageAuthLogin(page, loginUrl, credential);
const cookieState = await readAuthCookieState(context);
if (!response.ok || !cookieState.cookiePresent) {
const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
error.details = { accountId, status: response.status, statusText: response.statusText, cookiePresent: cookieState.cookiePresent, credentialSource: credential.source, valuesRedacted: true };
throw error;
}
const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
const navigation = await gotoTarget(target || targetPath);
const after = await accountSessionSnapshot();
return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
}
async function logoutAccount(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
const before = await accountSessionSnapshot();
const logoutUrl = new URL("/logout", baseUrl).toString();
const response = await page.evaluate(async (input) => {
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
await res.text().catch(() => "");
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
}, { logoutUrl });
await context.clearCookies().catch(() => {});
const cookieState = await readAuthCookieState(context);
const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
if (!result.ok) {
const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
error.details = result;
throw error;
}
return result;
}
async function listSessions(command = {}) {
const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
const snapshot = await workbenchSessionSnapshot();
const sessions = await page.evaluate(() => {
const seen = new Set();
const rows = [];
for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
if (!sessionId || seen.has(sessionId)) continue;
seen.add(sessionId);
rows.push({
sessionId,
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
status: element.getAttribute("data-status") || null,
conversationId: element.getAttribute("data-conversation-id") || null,
});
}
return rows.slice(0, 50);
}).catch(() => []);
return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
}
async function switchSessions(command) {
const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
const before = await accountSessionSnapshot();
if (fromAccountId) {
const beforeAccount = before.accountId || null;
await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
}
const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
const login = await loginAccount({ ...command, accountId: toAccountId });
const sessions = await listSessions({ ...command, accountId: toAccountId });
return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
}
async function accountSessionSnapshot() {
const cookieState = await readAuthCookieState(context);
const workbench = await workbenchSessionSnapshot().catch(() => null);
return {
url: currentPageUrl(),
path: safeUrlPath(currentPageUrl()),
cookiePresent: cookieState.cookiePresent,
cookieNames: cookieState.cookieNames,
activeSessionId: workbench?.activeSessionId || null,
routeSessionId: workbench?.routeSessionId || null,
tabCount: workbench?.tabCount ?? null,
messageCount: workbench?.messageCount ?? null,
valuesRedacted: true,
};
}
function requiredAccountId(command, keys) {
const accountId = commandValue(command, keys);
if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
return accountId;
}
function credentialForAccount(accountId) {
if (accountId === "bootstrap-admin" || accountId === "admin") {
if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
}
const env = accountCredentialEnvCandidates(accountId);
for (const jsonKey of env.jsonKeys) {
const raw = process.env[jsonKey];
if (!raw) continue;
const parsed = parseCredentialJson(raw);
if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
}
for (const pair of env.pairs) {
const user = process.env[pair.userKey];
const pass = process.env[pair.passKey];
if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
}
throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
}
function accountCredentialEnvCandidates(accountId) {
const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
return {
jsonKeys: [
"HWLAB_WEB_" + segment + "_JSON",
"HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
],
pairs: [
{ userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
{ userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
],
};
}
function parseCredentialJson(raw) {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
if (!user || !pass) return null;
return { username: user, password: pass, valuesRedacted: true };
} catch {
return null;
}
}
function isSafeAccountId(value) {
return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
}
function publicAuth(value) {