openclaw/scripts/mantis/observe-request-web-ui.mts
Martin Cleary a960844a9c
feat(qa): run isolated behavioral proof for inline reviews (#138953)
* feat(telegram): add isolated Test Server proof workflow

Add maintainer-only exact-head admission, durable at-most-once QA lease consumption, isolated candidate execution, and normalized trusted Telegram Test Server observations.

Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>

* feat(proof): bind named Web UI and canonical Telegram QA evidence

Reuse the existing formatting QA recipe, preserve isolated exact-candidate execution and produce the consumer request-bound receipt. Keep named smoke scenarios distinct and protect stationary harness ancestry.

Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>

* fix(proof): complete isolated QA execution and bounded failure capture

Reuse canonical ephemeral device pairing and QA RPC scopes, preserve strict startup probes and recorder locks, and retain bounded wrong-text attempts without Telegram delivery.

Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>

* fix(mantis): revoke proof forwarding and enforce lease roles

* fix(mantis): close proof producer CI gates

* test(mantis): consolidate related proof suites within CI budget

* test(mantis): preserve fast QA ownership when grouping integration cases

* fix(mantis): accept exact branch-qualified workflow paths

* fix(mantis): align live admission workflow path checks

* feat(mantis): collect selected proof inside the originating review

* fix(mantis): bound proof storage and preserve failure evidence

Reuse verified storage across request-bound candidates, reserve backing capacity, retain sanitized rejection evidence, and repair cleanup and observation finalization. Scoped checks and dirty review pass; full Gateway and sandboxed browser runtime proof remain required before publication.

* fix(mantis): retain bridge identity before startup

* fix(mantis): keep candidate config readable under private umask

* fix(qa): use verified rootless networking and join candidate shutdown

* fix(qa): repair proof tooling checks and deterministic recorder fixture

---------

Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>
2026-09-07 14:09:37 +01:00

287 lines
9.6 KiB
TypeScript

#!/usr/bin/env node
// Runs only in the trusted, network-disabled observer container.
import { createHash, randomUUID } from "node:crypto";
import { lstat, readFile, realpath, writeFile } from "node:fs/promises";
import path from "node:path";
import { Script } from "node:vm";
import { JSDOM } from "jsdom";
import { chromium, type WebSocketRoute } from "playwright";
import {
createControlUiMockBootstrapConfig,
createControlUiMockGatewayInitScript,
createControlUiMockSameOriginGatewayScript,
} from "../../ui/src/test-helpers/control-ui-e2e.ts";
// A loopback origin preserves the browser secure-context contract without
// starting a server: the driver fulfills every HTTP request in this namespace.
const origin = "http://127.0.0.1:4173";
const socketUrl = "ws://127.0.0.1:4173/";
const [bundleArg, outputArg] = process.argv.slice(2);
if (!bundleArg || !outputArg || process.argv.length !== 4) {
throw new Error("Usage: observe-request-web-ui.mts <bundle> <output>");
}
const bundle = await realpath(bundleArg);
const output = await realpath(outputArg);
const prompt = `Mantis request ${randomUUID()}`;
const reply = `Mantis reply ${randomUUID()}`;
const sessionKey = "agent:main:main";
const scenario = {
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Mantis ready." }] }],
};
// This realm never loads candidate HTML or scripts. The existing mock protocol
// stays reusable, but its request inventory is no longer in the candidate page.
const mock = new JSDOM("", { url: `${origin}/`, runScripts: "outside-only" });
new Script(createControlUiMockGatewayInitScript(scenario)).runInContext(
mock.getInternalVMContext(),
);
const sockets = new Set<WebSocketRoute>();
let sendRequest: { id: string; params: unknown } | undefined;
let sendRequestCount = 0;
let resolveSend: (() => void) | undefined;
const sent = new Promise<void>((resolve) => {
resolveSend = resolve;
});
let trafficBytes = 0;
let protocolError = false;
let captureArmed = false;
const browser = await chromium.launch({ chromiumSandbox: true });
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
acceptDownloads: false,
});
const page = await context.newPage();
const diagnostics: string[] = [];
const recordDiagnostic = (text: string) => {
if (diagnostics.length < 40) {
diagnostics.push(text.slice(0, 500));
}
};
page.on("pageerror", (error) => recordDiagnostic(error.message));
page.on("requestfailed", (request) =>
recordDiagnostic(`Failed asset: ${new URL(request.url()).pathname}`),
);
const mime: Record<string, string> = {
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
try {
await context.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin || route.request().method() !== "GET") {
await route.abort();
return;
}
if (url.pathname === "/control-ui-config.json") {
await route.fulfill({ json: createControlUiMockBootstrapConfig(scenario) });
return;
}
let relative: string;
try {
relative = decodeURIComponent(url.pathname).slice(1);
} catch {
await route.abort();
return;
}
// Only immutable exported regular files enter the browser; never execute
// candidate server/config code in the observer or expose the harness tree.
if (!relative || relative === "chat" || relative.startsWith("chat/")) {
relative = "index.html";
}
if (
relative.includes("\\") ||
relative.split("/").some((part) => part === ".." || part === ".")
) {
await route.abort();
return;
}
const file = path.resolve(bundle, relative);
try {
const actual = await realpath(file);
const stat = await lstat(file);
if (
!actual.startsWith(`${bundle}${path.sep}`) ||
actual !== file ||
!stat.isFile() ||
stat.size > 16 * 1024 * 1024
) {
throw new Error("unsafe asset");
}
await route.fulfill({
body: await readFile(file),
contentType: mime[path.extname(file)] ?? "application/octet-stream",
});
} catch {
await route.abort();
}
});
await context.routeWebSocket(/.*/, (route) => {
if (route.url() !== socketUrl || sockets.size >= 4) {
void route.close().catch(() => {});
return;
}
sockets.add(route);
const socket = new mock.window.WebSocket(socketUrl);
const queued: string[] = [];
socket.addEventListener("open", () => {
for (const message of queued.splice(0)) {
socket.send(message);
}
});
socket.addEventListener("message", (event) => {
const raw = String(event.data);
route.send(raw);
const frame = JSON.parse(raw);
if (frame.type === "res" && frame.id === sendRequest?.id) {
if (frame.ok !== true) {
protocolError = true;
}
resolveSend?.();
}
});
route.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
trafficBytes += Buffer.byteLength(raw);
if (trafficBytes > 1024 * 1024 || raw.length > 64 * 1024) {
protocolError = true;
void route.close().catch(() => {});
return;
}
try {
const frame: unknown = JSON.parse(raw);
if (
captureArmed &&
frame &&
typeof frame === "object" &&
"method" in frame &&
frame.method === "chat.send" &&
"id" in frame &&
typeof frame.id === "string" &&
"params" in frame
) {
sendRequestCount += 1;
sendRequest ??= { id: frame.id, params: frame.params };
if (sendRequestCount > 1) {
protocolError = true;
}
}
if (socket.readyState === mock.window.WebSocket.OPEN) {
socket.send(raw);
} else {
queued.push(raw);
}
} catch {
protocolError = true;
void route.close().catch(() => {});
}
});
route.onClose(() => {
socket.close();
sockets.delete(route);
});
});
await context.addInitScript({ content: createControlUiMockSameOriginGatewayScript() });
await page.goto(`${origin}/chat`);
await page.getByText("Mantis ready.", { exact: true }).waitFor({ timeout: 30_000 });
captureArmed = true;
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message", exact: true }).click();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
sent,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("chat.send not observed")), 30_000);
}),
]);
} finally {
clearTimeout(timer);
}
const params = sendRequest?.params;
if (
!params ||
typeof params !== "object" ||
!("idempotencyKey" in params) ||
typeof params.idempotencyKey !== "string" ||
params.idempotencyKey.length > 256 ||
protocolError
) {
throw new Error("Incomplete request observation");
}
// Route.send belongs to the driver, not a candidate-page global. The reply
// is freshly chosen here and cannot be known before this observed request.
const frame = JSON.stringify({
type: "event",
event: "chat",
payload: {
message: { role: "assistant", content: [{ type: "text", text: reply }] },
runId: params.idempotencyKey,
sessionKey,
state: "final",
},
});
for (const socket of sockets) {
socket.send(frame);
}
const rendered = page.locator(".chat-thread-inner").getByText(reply, { exact: true });
await rendered.waitFor({ state: "visible", timeout: 30_000 });
await page.waitForTimeout(1000);
if (sendRequestCount !== 1 || protocolError) {
throw new Error("Unexpected chat.send count");
}
if (!(await rendered.isVisible())) {
throw new Error("Final reply is no longer visible");
}
const actualText = await rendered.textContent();
if (actualText !== reply) {
throw new Error("Final reply does not match the observed response");
}
// Capture after the final assertion, unlike the legacy working-state image.
await page.screenshot({ path: path.join(output, "final-reply.png"), fullPage: true });
const records = {
"chat-send.json": {
expected: { deliver: false, message: prompt, sessionKey },
actual: params,
request_count: sendRequestCount,
},
"final-reply.json": { expected: reply, actual: actualText },
};
for (const [name, record] of Object.entries(records)) {
await writeFile(path.join(output, name), `${JSON.stringify(record)}\n`, { flag: "wx" });
}
const inventory = [];
for (const name of ["chat-send.json", "final-reply.json", "final-reply.png"]) {
const bytes = await readFile(path.join(output, name));
inventory.push({ path: name, sha256: createHash("sha256").update(bytes).digest("hex") });
}
await writeFile(
path.join(output, "observer.json"),
JSON.stringify({ schema: "mantis.web-ui-observer.v1", inventory }),
{ flag: "wx" },
);
} catch (error) {
await page.screenshot({ path: path.join(output, "incomplete.png") }).catch(() => {});
await writeFile(
path.join(output, "incomplete.json"),
JSON.stringify({
diagnostics,
body: (
(await page
.locator("body")
.textContent()
.catch(() => "unavailable")) ?? "unavailable"
).slice(0, 4096),
}),
).catch(() => {});
throw error;
} finally {
await context.close();
await browser.close();
mock.window.close();
}