feat(web-probe): add multi-sentinel registry
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user